자바 · SQL · 웹(JSP·Servlet·Spring) 수업을 한 줄 요약 → 쉽게 말하면 → 개념 → 주석 달린 코드 → 핵심 정리 순서로 정리한 노트입니다. 복습하러 왔다면 왼쪽 맨 위 🔁 복습 탭에서 바로 시작하세요. 처음이거나 흐름이 헷갈린다면 🗺️ 큰 그림 탭으로 지도를 보고, 📘 개념서 탭에서 1장부터 차례대로 공부하세요.
마지막 업데이트: 2026-10-02📅 수업 42일차🗂️ 노트 카드 296장✏️ 시험 대비 187문제
카드 앞면을 보고 먼저 떠올린 뒤 뒤집어서 확인하세요. 읽어서 아는 것과 떠올려서 말할 수 있는 것은 다릅니다.
무엇을 복습할까요
몇 장 볼까요
🎯 오늘의 복습 추천
완료 체크한 카드0/0
다른 방법으로 복습하기
📊 전체 분량 한눈에 보기
41
📅 수업 진도 (일차)
11
☕ 자바 기초 항목
24
🧱 객체지향 항목
11
🛠️ 자바 활용 항목
8
🌐 네트워크 항목
12
🧪 실습과제 문제
50
🧩 코딩테스트 오답노트
46
🗄️ SQL 기초 항목
61
🖥️ 웹 개발 항목
24
🧭 학습 여정 단계
32
📖 개념 사전 항목
0/0
학습 완료 진도 (0%)
📝 내가 남긴 메모 모아보기
💾 내 진도 백업
완료 체크·즐겨찾기·메모·복습·시험 기록은 이 브라우저에만 저장됩니다(프론트엔드 노트와는 별도로 저장돼 서로 섞이지 않습니다). 캐시를 지우거나 다른 기기로 옮기면 사라지니, 가끔 내보내기로 백업해 두고 필요할 때 가져오기로 복원하세요.
📖 이 노트로 공부하는 방법
🎯 외우지 말고 이해하기 — 카드 하나를 제대로 씹어 먹는 4단계
요약과 비유를 먼저 — 한 줄 요약으로 뼈대를 잡고 쉽게 말하면으로 감을 잡습니다. 여기서 "아 그런 거구나" 소리가 안 나오면 아래를 읽어도 안 남습니다.
코드를 보고 결과를 먼저 예측 — 실행 결과는 접혀 있습니다. 펼치기 전에 어떤 값이 나올지 머릿속으로 답을 정하세요. 예측이 틀린 순간이 가장 많이 배우는 순간입니다. 맞히면 확인, 틀리면 왜 틀렸는지가 그대로 배움이 됩니다.
🤔 스스로 확인으로 되짚기 — 카드 아래 질문에 답을 펼치기 전에 소리 내어(또는 머릿속으로) 설명해 보세요. 읽어서 아는 것과 떠올려서 말할 수 있는 것은 완전히 다른 상태입니다. 막히는 지점이 곧 아직 모르는 지점입니다.
💼 실무·코딩테스트에서는 을 읽고 연결 — "이걸 어디에 쓰나"가 붙어야 개념이 머리에 남습니다. 문법 하나를 외우는 것보다 어떤 문제를 만났을 때 이 도구를 꺼내는지를 아는 게 실전 실력입니다.
막히면 이렇게 — 설명이 안 되는 카드는 ⭐ 헷갈리는 것으로 표시해 두고 다음 날 다시 오세요. 하루 뒤에 다시 떠올리는 것이 같은 날 열 번 읽는 것보다 오래 남습니다. 개념끼리 헷갈릴 때는 📖 개념 사전에서 "A vs B" 항목을 찾아 둘의 경계를 확인하세요.
이 노트는 자바 교안 197장 + 실습과제 12문제를 미리 정리해 둔 예습용으로 시작해, 2026-08-31부터는 데이터베이스(SQL) 과정까지 함께 담고 있습니다. 수업에서 진도가 나간 부분을 골라 읽으면 복습 노트로도 그대로 쓸 수 있습니다.
📅 수업 진도 탭에는 수업에서 직접 작성한 실습 코드가 날짜별로 정리돼 있습니다. "오늘 어디까지 나갔는지"와 "그게 어느 개념 카드로 이어지는지"를 한눈에 볼 수 있어 시험 범위를 가늠하기 좋습니다.
🧭 학습 여정 탭은 교안 전 범위를 "왜 이 순서로 배우는가" 하나의 흐름으로 엮은 24단계 지도입니다(자바 1~13 · SQL 14~17 · 웹 18~24). 지금 배우는 내용이 앞의 무엇 위에 서 있고 뒤의 무엇으로 이어지는지 헷갈릴 때 여기부터 보세요. 📖 개념 사전 탭은 진도와 상관없이 용어 하나만 찾아보는 곳이고, 특히 그룹 D는 빨간 에러 메시지를 그대로 찾는 사전입니다 — 코드가 안 돌아가면 NullPointer처럼 메시지 일부를 검색창에 쳐 보세요.
순서대로 본다면 ☕ 자바 기초 → 🧱 객체지향 → 🛠️ 자바 활용 → 🌐 네트워크 흐름을 따르세요. 기초는 "변수·연산·제어문", 객체지향은 "클래스가 메모리에 올라가는 방식", 활용은 "컬렉션·예외·입출력·스레드", 네트워크는 "소켓으로 서버 만들기"가 중심입니다.
카드는 처음에 접혀 있고 한 줄 요약과 쉽게 말하면(일상 비유)만 보입니다. 이 두 줄만 훑어도 전체 흐름이 잡혀요.
🧪 실습과제 탭에는 자바실습과제 12문제(약수·최대공약수·친화수·완전수·윤년·개미수열·로또·마방진·야구게임·달력·카드)의 풀이 단계와 핵심 코드가 정리돼 있습니다. 먼저 직접 풀어보고 막힐 때 펼쳐 보세요.
🗄️ SQL 기초 탭은 2026-08-31부터 시작한 데이터베이스 과정 노트입니다. HeidiSQL로 MySQL에 접속하는 것부터 SELECT · WHERE · 집계 · JOIN · 윈도우 함수 · 제약조건 · 뷰 · 인덱스 · 프로시저까지 46개 카드로 이어집니다. 각 카드의 실행 결과는 수업 자료를 실제 DB에 올려 돌려 본 출력이라, 쿼리를 직접 쳐 보기 전에 결과를 먼저 확인할 수 있어요. 자바와 마찬가지로 시험 대비 탭에 SQL 30문항이 들어 있습니다.
🧩 코딩테스트 탭은 학원에서 프로그래머스로 정기 실시하는 코딩테스트 오답노트입니다. 회차마다 JAVA·SQL 문제를 제출 코드 → 정답 풀이 → 주요 개념 순으로 정리했으니, 실전 문제 감각을 다질 때 훑어보세요.
객관식 시험을 앞두고 있다면 ✏️ 시험 대비 탭에서 문제를 풀어보세요. 한 문제씩 고르는 즉시 정답·해설이 나오고, 🔎 자세한 해설을 펼치면 보기 네 개를 하나씩 짚어 주는 풀이와 더 쉬운 정리가 이어집니다. ⭐ 즐겨찾기 · 🔥 어려움으로 표시해 둔 문제나 틀린 문제만 골라 다시 푸는 것도 됩니다. 아직 진도가 안 나간 단원까지 섞여 나오는 게 부담이라면 범위에서 📅 배운 데까지를 고르세요 — 수업 진도 탭에 정리된 만큼만 출제됩니다.
카드 제목 옆 완료 체크박스·☆ 버튼(헷갈리는 카드)·🔗 버튼(카드 링크 복사)을 활용하고, 하단 📝 내 메모 칸에 헷갈린 점을 적어두세요. 우하단 ⚡ 5분 복습은 카드의 "쉽게 말하면"만 뽑아 플래시카드로 넘겨 줍니다.
프론트엔드(HTML·CSS·JS·React) 노트는 🎨 프론트엔드 학습 노트에 따로 있습니다.
🗺️ 큰 그림 — 처음이라면 여기부터
수업마다 정리한 카드가 나무 한 그루씩이라면, 이 탭은 숲 전체를 위에서 내려다본 지도입니다. 자바부터 스프링까지 "결국 뭘 배운 건지", "꼭 알아야 할 게 뭔지", "면접·실무에서는 뭘 물어보는지"를 처음 보는 사람 눈높이로 한 장에 모았습니다.
💡 이렇게 보세요 — 처음엔 1~2번(지도·타임라인)만 3분 동안 훑고, 3번의 프로젝트 표와 4번의 단계 카드는 제목과 한 줄 요약만 읽어 내려가세요. 그다음 헷갈리는 단계 하나만 펼쳐서 읽으면 됩니다.
지도 — 배운 것들이 웹 서비스의 어느 칸인지
타임라인 — 왜 이 순서로 배웠고, 지금 어디인지
예제 프로젝트 따라가기 — 01~08 프로젝트가 각각 무엇을 하려고 만든 것이고 무엇이 바뀌었나
단계별 핵심 — 단계마다 꼭 알 것 · 헷갈리는 것 · 실무 · 면접 질문
계속 다시 나오는 큰 생각 5가지 — 과목은 달라도 같은 원리
비슷해서 헷갈리는 말 — 한 표로 정리
면접에서 이 과정을 설명한다면 — 30초 답변과 단골 질문
더 자세히 보고 싶으면 각 카드 아래 링크로 해당 노트에 바로 갑니다. 같은 흐름을 24단계로 자세히 쪼갠 건 🧭 학습 여정 탭이에요.
1먼저 지도 한 장 — 웹 서비스에서 "요청 한 번"이 지나가는 길
이 과정에서 배운 모든 것은 결국 아래 그림의 어느 한 칸입니다. 게시판에서 "글 목록" 버튼을 한 번 누르면 이 순서로 일이 일어나요. 새로운 걸 배울 때마다 "이건 그림의 어느 칸이지?"부터 물어보면 길을 잃지 않습니다.
① 브라우저 (화면)사용자가 보고 누르는 곳. 뼈대·꾸밈·동작을 만든다.HTML · CSS · JavaScript · React · Next.js
HTTP 요청 / 응답
② 서버 (자바 프로그램)요청을 받아 "무엇을 할지" 판단하고, 결과 화면이나 데이터를 돌려준다.Java · Servlet/JSP · Spring MVC
JDBC MyBatis
③ 데이터베이스 (창고)글·회원 같은 데이터를 표 모양으로 오래 보관한다.SQL · MariaDB(MySQL)
프런트엔드 = ① 칸(사용자 쪽), 백엔드 = ②·③ 칸(서버 쪽)입니다. 프런트엔드 과정에서 ①을 다 만들어 봤고, 지금 백엔드 과정에서 ②와 ③을 만들고 있어요. 둘을 이어 붙이면 "풀스택" 한 서비스가 됩니다.
2지금까지 어디를 왔나 — 과정 전체 타임라인
순서에는 이유가 있습니다. 앞 단계가 뒤 단계의 재료가 돼요. (프런트엔드 날짜는 강의자료 날짜 기준)
6월 말HTML · CSS완료화면의 뼈대와 꾸밈. 웹의 "결과물"이 어떻게 생겼는지부터 본다.
7월 초JavaScript완료화면에 동작을 붙인다. 마지막 AJAX에서 처음으로 "서버에 데이터를 달라고" 요청한다.
7월 말React · Next.js + 졸업 과제완료화면을 부품(컴포넌트)으로 조립하는 현대식 방법. StockDash로 총정리.
8/3 ~ 8/28Java (기초 → 객체지향 → 활용 → 네트워크)완료서버 프로그램을 쓸 언어. 객체지향이 이후 스프링까지 전부의 바탕이 된다.
8/31 ~ 9/9SQL · 데이터베이스완료데이터를 꺼내고 저장하는 언어. 게시판의 모든 기능이 결국 SQL 한 줄로 끝난다.
9/10 ~ 9/21웹 기초: JDBC → JSP → Servlet → MVC2완료자바와 DB, 자바와 브라우저를 손으로 직접 잇는다. 불편함을 몸으로 겪는 단계.
9/22 ~ 9/28Spring MVC + MyBatis완료손으로 하던 반복 작업을 프레임워크가 대신한다. 같은 게시판을 더 짧고 정돈되게.
9/29 ~ 지금답변형 게시판 — 페이징 · 답글 · 트랜잭션 · AOP📍 지금 여기"돌아가는" 게시판에서 "실무에서 쓸 만한" 게시판으로. 여러 쿼리를 한 묶음으로, 공통 기능은 바깥으로.
예정단위 테스트(JUnit) · 파일 업로드/다운로드 · 일정관리(캘린더) 게시판예정지금 만든 Spring MVC 구조 위에 기능을 더 얹는다.
예정Spring Boot · Thymeleaf · Spring Security 종합 프로젝트예정XML 설정을 걷어 내고, 로그인·권한까지 갖춘 "요즘 방식" 스프링으로 마무리.
3예제 프로젝트 따라가기 — 01부터 08까지, 같은 게시판이 어떻게 바뀌었나
웹 수업은 프로젝트 폴더 01~08로 진행됐습니다. 핵심은 "거의 같은 게시판을 여덟 번 다시 만들었다"는 점이에요. 매번 앞 프로젝트에서 불편했던 점 하나를 해결하려고 새 프로젝트를 만들었습니다. 그래서 각 프로젝트는 "앞에서 뭐가 불편했고 → 그걸 어떻게 바꿨나"로 보면 가장 잘 이해됩니다.
프로젝트
요청을 받는 곳
화면(View)
DB 접근
새로 등장한 것
01 회원·구매
JSP 파일마다 따로
JSP + 자바 코드
DAO마다 JDBC 직접
JDBC · DTO/DAO
02 게시판 MVC1
boardController.jsp 하나
JSP + 자바 코드
DataBase 부모 클래스 상속
컨트롤러 · forward/redirect
03 Servlet 기초
Servlet 클래스
—
— (DB 없음)
Servlet · Filter
04 게시판 MVC2
Servlet (*.board)
JSP + 자바 코드
02와 같음
MVC2 · URI로 분기
05 MVC2 + JSTL
Servlet
JSP + EL/JSTL
02와 같음
EL · JSTL
06 스프링 뼈대
DispatcherServlet
JSP (ViewResolver)
DBCP·MyBatis 설정만
Spring · Maven
07 스프링 게시판
@Controller
JSP + EL/JSTL
Service → DAO → MyBatis
DI · 계층 · Mapper
08 답변형 게시판
@Controller
JSP + include + Bootstrap
Service(@Transactional) → DAO
페이징 · 답글 · 트랜잭션 · AOP · 로그
표를 위에서 아래로 읽어 보세요. 굵게 표시한 칸이 그 프로젝트에서 바뀐 곳입니다. 한 번에 한 칸씩만 바뀌기 때문에, 바뀐 칸이 곧 그 프로젝트에서 배운 것입니다.
0101_userboard_sepa — 회원·구매 관리28~30일차 · 9/10~9/14자바(DAO)와 DB를 JDBC로 처음 연결하고, JSP로 회원·구매 목록·등록·수정·삭제 화면을 만든다.
이 프로젝트의 의도
지금까지 자바는 콘솔에서만, SQL은 HeidiSQL에서만 따로 돌렸어요. 01의 목표는 "브라우저에서 버튼을 누르면 → 자바가 → DB에서 꺼내 → 화면에 보여 준다"는 한 바퀴를 처음 완성하는 것입니다. 구조보다 "일단 연결이 된다"가 핵심이에요.
한 일
UserDao·BuyDao: SQL을 실행하는 메서드 (getAllUser, insertUser, getUser, updateUser, deleteUser)
userDto·BuyDto: 한 행을 담는 상자
JSP 파일 하나 = 화면 하나 (userList.jsp, userInsertFrom.jsp, userDetail.jsp …)
처리 전용 JSP(userInsert.jsp, userDel.jsp)는 DAO를 부르고 결과에 따라 다른 화면으로 이동
배운 것
JDBC 순서: 드라이버 → 연결 → PreparedStatement → 실행 → ResultSet → 닫기
조회는 executeQuery(), 등록·수정·삭제는 executeUpdate()
request.getParameter()는 항상 String, 한글은 setCharacterEncoding
sendRedirect로 처리 후 목록으로 보내기
외래키 때문에 구매 기록이 있는 회원은 바로 안 지워진다
남은 불편 → 02로
JSP마다 new UserDao()를 부르고 그 아래에 HTML을 그려서, 요청 처리와 화면이 한 파일에 섞여 있어요. 그리고 DAO마다 DB 접속 코드가 똑같이 반복됩니다. 이 두 가지를 정리하려고 02를 만듭니다.
0202_hkboard_MVC1 — 게시판, 컨트롤러의 시작31~33일차 · 9/15~9/17게시판을 새로 만들면서, DB 접속 코드는 부모 클래스로 모으고, 요청 처리는 JSP 하나(컨트롤러)로 모은다.
이 프로젝트의 의도
01의 두 가지 불편을 고칩니다. ① 반복되는 DB 접속 코드는 부모 클래스 DataBase에 한 번만 쓰고 DAO가 물려받게 하고, ② 여기저기 흩어진 처리 페이지 대신 요청을 받는 창구를 boardController.jsp 하나로 모읍니다. "어떤 요청이냐"는 command 값으로 구분해요.
boardController.jsp?command=boardlist처럼 command로 분기: 목록 · 글쓰기 폼 · 등록 · 상세 · 수정 · 삭제 · 여러 글 삭제
목록·상세는 request에 담아 forward, 등록·수정·삭제 뒤에는 redirect
체크박스 전체 선택 + 여러 글 삭제(JDBC batch + 트랜잭션)
배운 것
상속으로 중복 없애기 (자바 객체지향이 웹에서 처음 쓰임)
컨트롤러라는 역할: 요청을 받아 무엇을 할지 고르는 곳
forward(서버 안에서 넘김, 주소 그대로) vs redirect(브라우저가 다시 요청)
scope 4가지 — request에 담은 값은 forward해야 살아 있다
setAutoCommit(false) → commit/rollback (첫 트랜잭션)
01과 달라진 점 / 남은 불편 → 03·04로
요청 창구가 하나로 모인 건 좋은데, 그 창구가 여전히 JSP라서 자바 분기 코드와 HTML이 한 파일에 섞여 있습니다. "요청 처리는 자바 클래스가, 화면은 JSP가" 맡게 하려면 자바로 요청을 받는 방법(Servlet)이 필요해요. 그래서 03에서 Servlet만 따로 연습합니다.
0303_hello_servlet — Servlet과 Filter만 따로 연습33~34일차 · 9/17~9/18DB도 게시판도 없이, "자바 클래스가 웹 요청을 받는다"는 것 하나만 확인하는 연습 프로젝트.
이 프로젝트의 의도
02의 컨트롤러를 자바 클래스로 옮기기 전에, Servlet이 뭔지부터 작게 익히는 프로젝트예요. 게시판이 섞여 있으면 헷갈리니까 HelloServlet 하나와 EncodeFilter 하나만 둡니다. 콘솔에 찍히는 순서를 보면서 "언제 무엇이 실행되나"를 확인하는 게 목적입니다.
한 일
@WebServlet으로 주소 연결, initParams로 초기값 전달
init() · doGet() · doPost() · destroy()에 출력문을 넣어 실행 순서 관찰
0404_hkboard_MVC2 — 컨트롤러를 Servlet으로34일차 · 9/1802 게시판의 boardController.jsp를 자바 클래스 BoardController(Servlet)로 옮겨, 요청 처리와 화면을 완전히 나눈다.
이 프로젝트의 의도
02와 기능은 같은 게시판입니다. 바뀐 건 "요청을 받는 곳"뿐이에요. 이제 Controller = Servlet(자바), Model = DAO·DTO, View = JSP로 역할이 깔끔히 나뉩니다. 이 구조가 MVC2이고, 나중에 배우는 스프링도 이 구조 그대로입니다.
한 일
@WebServlet("*.board") — .board로 끝나는 모든 요청이 BoardController로
getRequestURI()에서 getContextPath()를 잘라 내 어떤 요청인지 구분 (예: /boardlist.board)
결과를 request.setAttribute로 담고 RequestDispatcher.forward로 JSP에 넘김
03의 EncodeFilter를 가져와 인코딩을 한 곳에서 처리
배운 것
MVC2와 프런트 컨트롤러: 모든 요청이 한 창구로 들어와 분기된다
command 파라미터 대신 주소(URI)로 요청을 구분하는 방법
수정·삭제 결과는 forward가 아니라 redirect로 응답해야 하는 이유
02와 달라진 점 / 남은 불편 → 05로
컨트롤러는 자바로 빠졌지만, JSP 화면 안에는 아직 <% for (HkDto dto : list) { %> 같은 자바 코드가 남아 있어요. 화면 담당자가 읽기 힘들고 실수하기 쉽습니다. 이걸 걷어 내는 게 05입니다.
0707_hkboard_springMVC — 게시판을 스프링 + MyBatis로37~38일차 · 9/23~9/2805까지의 게시판(목록·글쓰기·상세·수정·여러 글 삭제)을 Controller → Service → DAO → MyBatis Mapper 4층 구조로 다시 만든다.
이 프로젝트의 의도
기능은 02·04·05와 똑같은 게시판입니다. 같은 기능을 스프링 방식으로 다시 짜 보면서, "예전엔 내가 하던 일을 이제 누가 대신하나"를 비교하는 게 목적이에요. 새로 생긴 Service 계층과 MyBatis, 그리고 객체를 스프링이 넣어 주는 DI가 핵심입니다.
요청 하나가 지나가는 길 (07의 전부)
/boardlist.do → BoardController.boardList() → HkService.getAllList() → HkDao.getAllList() → sqlSession.selectList("com.hk.board.dao.boardList") → BoardMapper.xml의 <select id="boardList"> → DB → 결과가 거꾸로 올라와 model.addAttribute("list", …) → return "boardlist" → /WEB-INF/views/boardlist.jsp
0808_answerboard_springMVC — 답변형 게시판📍 진행 중 · 39~42일차 · 9/29~10/207 구조 위에 답글 · 페이지 번호 · 조회수 · 논리 삭제를 올리고, 트랜잭션 · AOP · 로그로 실무형 게시판을 만든다.
이 프로젝트의 의도
07까지는 구조를 배웠다면, 08은 실무에서 꼭 부딪히는 문제를 풉니다. "글이 1,000개면?"(페이징), "답글은 어디에 끼우지?"(refer·step·depth), "두 쿼리 중 하나만 성공하면?"(트랜잭션), "모든 메서드에 로그를 남기려면?"(AOP). 구조는 07과 같고, 기능과 품질이 올라갑니다.
목록: ROW_NUMBER()로 번호 매겨 10개씩, Paging 유틸로 아래 페이지 번호(5개씩) 계산, pnum을 계속 들고 다님
상세를 열 때 조회수 +1 (redirect로 새로고침 중복 방지), 삭제는 delflag='Y'(논리 삭제)
답글: 아래 글들 step+1(UPDATE) → 그 자리에 INSERT, 두 작업을 @Transactional로 묶음
LogExecute(AOP)로 메서드 실행 전후 로그, SLF4J + Logback 로거
header·footer를 <jsp:include>로 나누고 Bootstrap, Controller는 생성자 주입
배운 것
페이징: 전체 글 수 → 페이지 수, 현재 페이지 → 가져올 범위
트랜잭션: 여러 쿼리를 "전부 되거나 전부 취소"로. Service 메서드가 그 단위
AOP: 공통 기능(로그)을 핵심 코드 수정 없이 바깥에서 붙이기. @Transactional도 같은 원리(프록시)
로그: System.out 대신 레벨(debug·info·warn·error)이 있는 로거
생성자 주입과 "Controller는 얇게, 조립은 Service로"
앞으로
단위 테스트(JUnit)가 아직 남아 있고, 이후 파일 업로드 · 캘린더 게시판을 거쳐 Spring Boot로 넘어갑니다. Boot로 가면 06에서 세운 XML 설정 대부분이 사라지지만, 07·08에서 익힌 Controller → Service → DAO 구조는 그대로 갑니다.
02객체지향(OOP) — 설계도와 실체완료 · 4~14일차데이터와 기능을 "객체" 하나로 묶고, 물려받고(상속), 갈아 끼울 수 있게(다형성) 만든다. 스프링까지 이어지는 자바의 핵심.
쉽게 말하면
클래스는 붕어빵 틀, 객체는 구워진 붕어빵입니다. 틀 하나로 붕어빵(객체)을 여러 개 찍어 내고, 각 붕어빵은 자기 속(필드 값)을 따로 가져요. "팥 붕어빵"은 "붕어빵"을 물려받아 속만 바꾼 것(상속)이고, 손님은 무슨 붕어빵이든 "붕어빵 주세요"라고만 하면 됩니다(다형성).
꼭 알아야 할 것
클래스(설계도) → new → 객체(메모리에 만들어진 실체)
생성자: 객체가 만들어질 때 딱 한 번 실행, 필드 초기화
캡슐화: 필드는 private, 접근은 getter/setter로
상속extends + 오버라이딩(물려받은 메서드 고쳐 쓰기)
다형성: Animal a = new Dog(); — 부모 타입으로 자식을 다룬다
인터페이스: "이 기능이 있다"는 약속. 구현은 클래스가 한다
초보가 자주 헷갈리는 것
오버로딩(같은 이름, 다른 매개변수) vs 오버라이딩(물려받은 메서드 재정의)
static은 클래스에 하나, 일반 필드는 객체마다 하나
문자열 비교는 ==(주소 비교)가 아니라 equals()(내용 비교)
참조타입 변수에는 객체가 아니라 객체의 주소가 들어 있다 → b = a는 객체가 아니라 주소를 복사하므로 둘이 같은 객체를 가리킨다
자바의 매개변수 전달은 항상 값 복사 — 참조타입은 "주소값"이 복사될 뿐이다
추상 클래스(공통 코드 + 미완성 메서드) vs 인터페이스(약속만, 다중 구현 가능)
실무에서는
스프링 코드를 열면 BoardService, BoardDao, BoardDto 같은 클래스가 역할별로 나뉘어 있죠? 전부 객체지향입니다. 특히 "인터페이스로 약속하고 구현은 갈아 끼운다"는 생각이 스프링의 의존성 주입(DI)으로 그대로 이어집니다. 싱글턴·팩토리 같은 디자인 패턴도 여기서 처음 만났어요.
면접에서는
객체지향의 특징(4대 특성)을 설명해 보세요.
캡슐화(데이터를 숨기고 메서드로만 접근), 상속(기존 클래스를 물려받아 재사용), 다형성(같은 타입·같은 호출이 실제 객체에 따라 다르게 동작), 추상화(공통 특징만 뽑아 틀로 정의)입니다.
오버로딩과 오버라이딩의 차이는?
오버로딩은 한 클래스 안에서 이름은 같고 매개변수가 다른 메서드를 여러 개 만드는 것, 오버라이딩은 상속받은 메서드를 자식 클래스에서 같은 모양으로 다시 정의하는 것입니다.
추상 클래스와 인터페이스는 언제 쓰나요?
공통 코드(필드·구현된 메서드)를 물려주면서 일부만 자식에게 맡길 때는 추상 클래스, 서로 관련 없는 클래스들에게 "이 기능을 가져라"는 약속만 정할 때는 인터페이스를 씁니다. 클래스는 하나만 상속하지만 인터페이스는 여러 개 구현할 수 있습니다.
==와 equals()의 차이는?
==는 참조타입에서 같은 객체(주소)인지, equals()는 내용이 같은지를 비교합니다. String 비교는 equals()를 써야 합니다.
03자바 활용 — 컬렉션 · 예외 · 람다 · IO · 스레드완료 · 13~18일차실제 프로그램에 꼭 필요한 도구 상자. 여러 개 담기, 실패 대비하기, 짧게 쓰기, 파일 읽고 쓰기, 동시에 하기.
쉽게 말하면
객체지향이 "부품 만드는 법"이었다면, 이건 미리 만들어진 공구 세트입니다. 목록 상자(List), 중복 없는 주머니(Set), 이름표 서랍(Map), 그리고 "일이 틀어졌을 때의 비상 계획(예외 처리)"까지요.
꼭 알아야 할 것
List(순서 O·중복 O) / Set(중복 X) / Map(키 → 값)
제네릭 List<String> — 담을 타입을 미리 정해 실수를 컴파일 때 잡는다
try-catch-finally: 실패해도 프로그램이 죽지 않게. finally는 항상 실행
람다 (a, b) -> a + b와 Stream filter → map → collect
스레드: 여러 일을 동시에. 같은 데이터를 건드리면 synchronized
초보가 자주 헷갈리는 것
배열은 크기 고정, ArrayList는 크기가 늘어난다
Checked 예외(컴파일러가 처리 강제, 예: IOException) vs Unchecked 예외(RuntimeException 계열, 예: NullPointerException)
throw(예외를 지금 던진다) vs throws(이 메서드는 예외를 던질 수 있다고 선언)
스레드의 run()을 직접 부르면 그냥 메서드 호출 — start()를 불러야 새 스레드
실무에서는
DB에서 꺼낸 글 목록은 List<BoardDto>, 화면에 넘길 값 묶음은 Map으로 다룹니다. 지금 게시판 코드의 Map<String, Object> resultMap이 바로 그것이에요. 예외 처리는 "에러 화면 대신 안내 페이지로 보내기", "DB 연결은 반드시 닫기"로 쓰이고, 스레드는 톰캣이 요청마다 알아서 나눠 처리해 줍니다.
면접에서는
ArrayList와 LinkedList의 차이는?
ArrayList는 배열 기반이라 인덱스로 조회가 빠르고 중간 삽입·삭제가 느립니다. LinkedList는 노드를 연결한 구조라 중간 삽입·삭제는 빠르지만 조회는 처음부터 따라가야 해서 느립니다. 대부분은 ArrayList를 씁니다.
HashMap은 어떻게 빠르게 값을 찾나요?
키의 hashCode()로 저장 위치(버킷)를 바로 계산해서 찾고, 같은 버킷 안에서는 equals()로 정확한 키를 고릅니다. 그래서 키로 쓰는 객체는 두 메서드를 함께 재정의해야 합니다.
Checked Exception과 Unchecked Exception의 차이는?
Checked는 컴파일러가 try-catch나 throws로 처리를 강제하는 예외(IOException, SQLException 등), Unchecked는 RuntimeException의 자손으로 처리를 강제하지 않는 예외(NullPointerException 등)입니다.
04네트워크 — 컴퓨터끼리 대화하기완료 · 18~19일차IP 주소로 컴퓨터를 찾고, 포트로 프로그램을 찾고, 소켓으로 말을 주고받는다. 웹이 동작하는 바닥.
쉽게 말하면
IP 주소는 아파트 주소, 포트 번호는 호수입니다. 같은 컴퓨터(아파트)에도 프로그램(집)이 여러 개라 호수까지 알아야 찾아갈 수 있어요. TCP는 등기 우편(도착 확인, 순서 보장), UDP는 전단지(빠르지만 보장 없음)예요.
꼭 알아야 할 것
TCP = 연결 후 신뢰성 있게 전송 / UDP = 연결 없이 빠르게
서버: ServerSocket으로 기다리다 accept() / 클라이언트: Socket으로 접속
여러 손님을 동시에 받으려면 손님마다 스레드를 하나씩
웹(HTTP)은 TCP 위에서 돌아가는 약속이다 (기본 포트 80, HTTPS는 443)
왜 배웠나 (다음과의 연결)
소켓 채팅 서버를 직접 만들어 보면 "서버는 요청을 기다리다가, 손님마다 스레드로 응답한다"는 감각이 생깁니다. 톰캣이 바로 이 일을 대신해 주는 프로그램이에요. 그래서 웹 과정에서는 소켓을 직접 짜지 않고 요청 처리 코드만 씁니다. 톰캣의 기본 포트가 8080인 것도 이제 이해되죠.
면접에서는
TCP와 UDP의 차이는?
TCP는 연결을 맺고(3-way handshake) 순서와 도착을 보장하는 신뢰성 있는 전송으로 웹·이메일에 쓰입니다. UDP는 연결 없이 보내 빠르지만 손실·순서 뒤바뀜을 보장하지 않아 실시간 영상·게임·DNS에 쓰입니다.
브라우저 주소창에 URL을 치면 무슨 일이 일어나나요?
DNS로 도메인을 IP로 바꾸고, 그 IP의 서버 포트로 TCP 연결을 맺은 뒤 HTTP 요청을 보냅니다. 서버(웹 서버·WAS)가 요청을 처리해 HTML 같은 응답을 돌려주면 브라우저가 그것을 해석해 화면에 그립니다. (단골 질문이니 지도 1번 그림과 같이 기억하세요.)
실행 순서: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
JOIN: 나눠 저장한 표를 공통 열로 다시 잇는다 (INNER / LEFT OUTER)
기본키(PK)는 행의 주민번호, 외래키(FK)는 다른 표를 가리키는 연결선
인덱스는 책의 찾아보기 — 조회는 빨라지고, 저장·수정은 조금 느려진다
트랜잭션: 여러 작업을 "전부 성공 아니면 전부 취소"로 묶기 (COMMIT / ROLLBACK)
초보가 자주 헷갈리는 것
WHERE(묶기 전 행을 거름) vs HAVING(묶은 뒤 그룹을 거름)
NULL은 0도 빈칸도 아닌 "모름" → = NULL이 아니라 IS NULL
UPDATE · DELETE에서 WHERE를 빼면 표 전체가 바뀐다
DELETE(행 삭제, 되돌릴 수 있음) · TRUNCATE(전부 비움) · DROP(표 자체 삭제)
GROUP BY는 행을 접고, 윈도우 함수(OVER)는 행을 그대로 둔 채 옆에 붙인다
실무에서는
백엔드 개발자 하루의 상당 부분이 "이 화면에 필요한 데이터를 어떤 SQL로 가져올까"입니다. 게시판 목록 = SELECT ... ORDER BY ... LIMIT, 글쓰기 = INSERT, 답글 = UPDATE + INSERT(트랜잭션). 느린 화면을 고칠 땐 EXPLAIN으로 인덱스를 타는지부터 봅니다. 프로그래머스 SQL 문제도 코딩테스트 단골이에요.
면접에서는
INNER JOIN과 LEFT OUTER JOIN의 차이는?
INNER JOIN은 양쪽 표에 짝이 있는 행만, LEFT OUTER JOIN은 왼쪽 표의 행은 짝이 없어도 모두 남기고 오른쪽 값은 NULL로 채웁니다. "주문이 한 번도 없는 회원 찾기"처럼 없는 것을 찾을 때 OUTER JOIN을 씁니다.
인덱스는 무엇이고, 무조건 많이 만들면 좋나요?
원하는 행을 빨리 찾기 위한 별도의 정렬된 자료구조(목차)입니다. 조회는 빨라지지만 INSERT·UPDATE·DELETE 때마다 인덱스도 고쳐야 해서 느려지고 공간도 듭니다. 자주 조회 조건에 쓰이는 열에만 만듭니다.
트랜잭션의 ACID를 설명해 보세요.
원자성(전부 되거나 전부 안 되거나), 일관성(규칙을 깨는 상태로 끝나지 않음), 격리성(동시에 실행돼도 서로 간섭하지 않음), 지속성(커밋된 결과는 장애가 나도 남음)입니다.
정규화란?
같은 데이터가 여러 곳에 중복 저장되지 않도록 표를 나누는 설계 방법입니다. 중복이 줄어 수정 시 불일치가 사라지는 대신, 조회할 때 JOIN이 필요해집니다.
06웹 1 — JDBC · DTO/DAO · JSP (MVC1)완료 · 28~32일차자바와 DB를 JDBC로 잇고, JSP로 화면을 만들어 첫 회원관리·게시판을 완성한다. 모든 걸 손으로 직접.
쉽게 말하면
지금까지 자바(②)와 DB(③)를 따로 배웠죠? 여기서 처음으로 둘 사이에 다리(JDBC)를 놓습니다. 그리고 결과를 브라우저(①)에 보여 줄 화면을 JSP로 만들어요. DTO는 택배 상자(데이터를 담아 나름), DAO는 창고 직원(DB 출입을 전담)입니다.
꼭 알아야 할 것
JDBC 순서: 드라이버 로딩 → 연결(Connection) → SQL 준비(PreparedStatement) → 실행 → 결과(ResultSet) 읽기 → 자원 닫기
DTO: 한 행의 데이터를 담는 객체 / DAO: SQL을 실행하는 객체
JSP = HTML 안에 자바 코드를 끼워 넣은 파일. 서버가 실행해서 HTML로 바꿔 보낸다
request.getParameter("이름") — 화면에서 온 값은 항상 String
화면 하나가 요청 받기·처리·출력을 다 하는 구조 = MVC1
초보가 자주 헷갈리는 것
Statement에 문자열을 이어 붙이면 SQL 인젝션 위험 → PreparedStatement와 ?
한글이 깨지면 request.setCharacterEncoding("UTF-8")을 값 읽기 전에
WEB-INF 안 파일은 주소로 직접 못 연다 (일부러 숨긴 곳)
외래키로 연결된 회원은 구매 기록부터 지워야 삭제된다
실무에서는
요즘은 JDBC 코드를 직접 쓰지 않고 MyBatis나 JPA를 씁니다. 하지만 그 도구들도 속에서는 전부 JDBC를 쓰고 있어서, 에러가 나면 이 단계의 지식으로 원인을 찾아요. DTO/DAO로 역할을 나누는 습관은 그대로 이어지고, PreparedStatement로 SQL 인젝션을 막는 건 보안의 기본입니다.
면접에서는
SQL 인젝션이 무엇이고 어떻게 막나요?
사용자 입력에 SQL 조각을 섞어 넣어 의도하지 않은 쿼리를 실행시키는 공격입니다. 문자열을 이어 붙여 SQL을 만들지 말고, PreparedStatement의 ? 바인딩(MyBatis에서는 #{})으로 값을 "데이터"로만 전달해 막습니다.
DTO와 DAO는 왜 나누나요?
데이터를 담는 역할과 DB에 접근하는 역할을 분리하면, DB 쿼리가 바뀌어도 DAO만 고치면 되고 화면·로직 코드는 DTO만 주고받으면 됩니다. 변경의 영향이 한 곳에 모입니다.
07웹 2 — Servlet · MVC2 · EL/JSTL완료 · 31~35일차요청을 받는 "컨트롤러(Servlet)"와 보여 주는 "화면(JSP)"을 나눈다. 스프링이 하는 일의 원형.
쉽게 말하면
식당으로 치면, MVC1은 요리사가 주문도 받고 서빙도 하는 가게예요. MVC2는 역할을 나눕니다. 주문 받는 직원(Controller = Servlet)이 주문을 받아 주방(Model = Service·DAO)에 넘기고, 완성된 요리를 접시(View = JSP)에 담아 내보내죠. 한 사람이 다 하지 않으니 고치기 쉬워집니다.
꼭 알아야 할 것
Servlet: 요청을 받는 자바 클래스. doGet / doPost
forward(서버 안에서 넘김, 주소 그대로, request 유지) vs redirect(브라우저에게 다시 요청하라고 시킴, 주소 바뀜)
저장·수정·삭제 뒤엔 redirect(새로고침해도 중복 저장 안 됨), 조회 결과 보여 줄 땐 forward
request.setAttribute로 담은 값은 redirect하면 사라진다
Web Server(정적 파일) vs WAS(톰캣, 자바 코드를 실행해 동적 페이지 생성)
04 → 05 프로젝트에서 바뀐 건 화면(View)뿐 — 역할을 나눴기 때문에 가능
실무에서는
Servlet을 직접 쓰는 일은 드물지만, 스프링 MVC는 "아주 잘 만든 Servlet 하나(DispatcherServlet)"입니다. 그래서 요청이 안 들어오거나 값이 비어 있을 때, 여기서 배운 요청 흐름·scope·forward/redirect 지식으로 원인을 찾습니다. 세션·필터는 로그인과 보안(나중에 Spring Security)의 바탕이에요.
면접에서는
forward와 redirect의 차이는?
forward는 서버 내부에서 다른 페이지로 요청을 넘기는 것으로, 브라우저 주소가 바뀌지 않고 request 데이터가 유지됩니다. redirect는 브라우저에게 새 주소로 다시 요청하라고 응답하는 것으로, 주소가 바뀌고 request는 새로 만들어집니다. 글 저장 후에는 중복 제출을 막으려고 redirect를 씁니다(PRG 패턴).
세션과 쿠키의 차이는?
쿠키는 브라우저에 저장되는 작은 데이터, 세션은 서버에 저장되고 브라우저는 세션 ID(쿠키)만 들고 있습니다. 세션이 더 안전해서 로그인 정보는 세션에 둡니다.
MVC 패턴을 설명해 보세요.
Model(데이터와 비즈니스 로직), View(화면), Controller(요청을 받아 Model을 호출하고 View를 고름)로 역할을 나누는 구조입니다. 화면과 로직이 분리돼 한쪽을 바꿔도 다른 쪽이 덜 흔들립니다.
Servlet의 생명주기는?
첫 요청 때 객체가 하나 만들어져 init()이 한 번 실행되고, 이후 요청마다 service()(→ doGet/doPost)가 스레드별로 실행되며, 서버가 내려갈 때 destroy()가 실행됩니다.
08웹 3 — Spring MVC + MyBatis완료 · 36~38일차직접 짜던 요청 분배·객체 생성·DB 연결을 스프링과 MyBatis가 대신한다. 개발자는 "할 일"만 쓴다.
쉽게 말하면
지금까진 식당 주인이 직원 채용·배치·교육까지 직접 했어요. 스프링은 인력 관리 회사입니다. "이 자리엔 Service 직원이 필요해"라고 적어 두면(@Autowired, 생성자) 스프링이 알아서 만들어서 넣어 줘요(의존성 주입, DI). MyBatis는 SQL 전문 비서 — SQL만 XML에 적어 두면 JDBC 반복 코드를 대신 처리합니다.
요청이 지나가는 길 (이것만은 외워 두세요)
브라우저 → DispatcherServlet(모든 요청의 정문) → @Controller(어떤 요청인지 보고 분배) → @Service(업무 로직) → DAO → MyBatis Mapper.xml(SQL) → DB → 결과가 거꾸로 올라와 Model에 담김 → ViewResolver가 JSP를 찾아 → 브라우저
꼭 알아야 할 것
IoC(제어의 역전): 객체를 내가 new하지 않고 스프링(컨테이너)이 만들고 관리 → 이렇게 관리되는 객체가 Bean
DI: 필요한 객체를 밖에서 넣어 준다. 요즘은 생성자 주입을 권장
@RequestMapping("/boardList.do") — 주소 하나에 메서드 하나
폼 값은 @RequestParam이나 DTO(커맨드 객체)로 자동으로 들어온다
MyBatis: #{값}으로 안전하게 바인딩, <foreach>로 여러 글 삭제
Maven pom.xml: 필요한 라이브러리(jar)를 적으면 자동으로 받아 온다
초보가 자주 헷갈리는 것
Controller는 얇게 — 요청 받고 결과 고르기만. 계산·조립은 Service로
Mapper XML의 namespace + id가 DAO에서 부르는 이름과 정확히 같아야 한다
@RequestParam에 이름을 안 적으면 컴파일 옵션(-parameters)에 따라 예외가 난다
Spring(프레임워크 본체) ≠ Spring Boot(설정을 자동으로 해 주는 시작 도구)
실무에서는
국내 자바 백엔드 실무의 대부분이 스프링 + (MyBatis 또는 JPA)입니다. 특히 SI·공공·금융 쪽은 MyBatis가 여전히 많아요. 새 기능을 만들 땐 거의 항상 Controller → Service → DAO(Mapper) → SQL 네 파일을 같이 고칩니다. 에러가 나면 이 순서를 따라가며 "어디까지는 값이 제대로 왔나"를 로그로 좁혀 가요.
면접에서는
IoC와 DI를 설명해 보세요.
IoC는 객체의 생성과 연결을 개발자가 아니라 스프링 컨테이너가 맡는 것이고, DI는 그 방법으로 객체가 필요로 하는 다른 객체를 외부에서 주입받는 것입니다. 구현체를 바꿔 끼우기 쉬워지고 테스트하기 좋아집니다.
생성자 주입을 권장하는 이유는?
필드를 final로 만들 수 있어 바뀌지 않음이 보장되고, 필요한 의존성이 없으면 객체 생성 자체가 실패해 실수를 빨리 발견합니다. 테스트할 때 가짜 객체를 넣기도 쉽습니다.
DispatcherServlet의 역할은?
모든 요청을 가장 먼저 받는 프런트 컨트롤러로, 요청 주소에 맞는 컨트롤러 메서드를 찾아 실행하고 반환된 뷰 이름을 ViewResolver로 실제 화면에 연결합니다.
MyBatis에서 #{}와 ${}의 차이는?
#{}는 PreparedStatement의 ?로 바뀌어 값으로 안전하게 들어가고, ${}는 문자열 그대로 SQL에 붙어 SQL 인젝션 위험이 있습니다. 기본은 #{}, 정렬 컬럼명처럼 꼭 필요할 때만 검증 후 ${}를 씁니다.
09웹 4 — 답변형 게시판: 페이징 · 답글 · 트랜잭션 · AOP📍 진행 중 · 39~42일차게시판을 "실무에서 쓸 만하게" 다듬는다. 많은 글은 나눠 보여 주고, 여러 쿼리는 한 묶음으로, 공통 기능은 바깥으로.
쉽게 말하면
답글을 하나 달려면 DB에 두 가지 일을 해야 해요. ① 아래 글들을 한 칸씩 밀고(UPDATE) ② 그 자리에 답글을 넣기(INSERT). 그런데 ①만 되고 ②가 실패하면? 글 순서만 엉망이 됩니다. 트랜잭션은 "둘 다 되거나, 둘 다 없던 일로" 묶는 장치예요. AOP는 모든 메서드에 똑같이 붙는 일(로그 남기기 등)을 각 메서드를 고치지 않고 바깥에서 한 번에 붙이는 기술입니다.
꼭 알아야 할 것
페이징: 수업 코드는 ROW_NUMBER()로 글마다 번호를 매기고 ceil(rn/10) = pnum인 10개만 꺼낸다 + 현재 페이지 번호(pnum)를 글쓰기·상세·수정 화면까지 계속 들고 다닌다
답글 정렬: refer(원글 그룹) · step(그룹 안 순서) · depth(들여쓰기)
논리 삭제: 실제로 지우지 않고 delflag 같은 표시만 → 답글 구조가 안 깨진다
@Transactional: Service 메서드에 붙이면 안의 쿼리들이 한 트랜잭션. 런타임 예외가 나면 자동 롤백
AOP: 공통 기능(Advice)을 어디에(Pointcut) 붙일지 정한다 — @Before, @AfterReturning, @AfterThrowing
로그: System.out 대신 SLF4J(창구) + Logback(실제 출력), 레벨은 debug < info < warn < error
초보가 자주 헷갈리는 것
@Transactional은 Service에 붙인다 — "업무 하나"의 단위가 Service 메서드이기 때문
같은 클래스 안에서 자기 메서드를 부르면 트랜잭션·AOP가 안 걸린다 (스프링이 바깥에 씌운 대리인(프록시)을 거치지 않으니까)
설정 파일이 둘(root / servlet)로 나뉘면 어느 쪽이 무엇을 스캔하는지 꼬이기 쉽다 → 트랜잭션이 조용히 안 걸릴 수 있음
체크 예외(Exception)는 기본적으로 롤백되지 않는다
실무에서는
"주문하면 재고를 줄이고 결제 기록을 남긴다", "송금하면 내 잔액을 빼고 상대 잔액을 더한다" — 돈과 관련된 모든 기능이 트랜잭션입니다. 로그는 운영 중 장애를 추적하는 유일한 단서라, 실무에선 System.out.println을 거의 쓰지 않아요. AOP는 로그 외에도 권한 검사, 실행 시간 측정, 트랜잭션 자체(@Transactional도 AOP로 동작)에 쓰입니다.
면접에서는
@Transactional은 어떻게 동작하나요?
스프링이 해당 Bean을 감싸는 프록시를 만들어, 메서드 실행 전에 트랜잭션을 시작하고 정상 종료 시 커밋, 런타임 예외가 나면 롤백합니다. AOP 방식이라 프록시를 거치지 않는 같은 클래스 내부 호출에는 적용되지 않습니다.
AOP란 무엇이고 어디에 쓰나요?
여러 곳에 반복되는 공통 관심사(로그, 트랜잭션, 보안 검사, 실행 시간 측정)를 핵심 로직에서 떼어 내 한 곳에 정의하고, 정해진 지점에 자동으로 끼워 넣는 프로그래밍 방식입니다.
페이징은 어떻게 구현하나요?
전체 글 수로 총 페이지 수를 계산하고, 현재 페이지 번호에 해당하는 범위만 DB에서 조회합니다. MariaDB·MySQL에서는 보통 LIMIT 개수 OFFSET 시작을 쓰고, 수업에서는 ROW_NUMBER()로 번호를 매겨 잘랐습니다. 화면 아래의 페이지 번호 묶음(예: 1~5, 6~10)도 현재 페이지로 계산합니다.
물리 삭제 대신 논리 삭제를 쓰는 이유는?
실수로 지운 데이터를 복구할 수 있고, 다른 데이터(답글, 주문 기록 등)와의 연결이 깨지지 않으며, 언제 무엇이 지워졌는지 기록이 남기 때문입니다.
10앞으로 배울 것 — 미리 감 잡기예정단위 테스트, 파일 업로드, 캘린더 게시판, 그리고 Spring Boot · Thymeleaf · Spring Security로 마무리.
JUnit 단위 테스트
브라우저로 일일이 눌러 보지 않고, 코드로 "이 메서드가 이 값을 돌려주는지" 자동으로 확인합니다. 생성자 주입을 배운 이유 중 하나가 여기서 드러나요 — 가짜 DAO를 넣어 Service만 따로 시험할 수 있거든요.
파일 업로드 · 다운로드
폼에 enctype="multipart/form-data"를 붙여 파일을 보내고, 서버는 파일을 디스크에 저장한 뒤 DB에는 파일 경로·이름만 저장합니다. 같은 이름 덮어쓰기를 막으려고 저장할 땐 이름을 바꿔요(UUID 등).
일정관리(캘린더) 게시판
자바 실습과제의 달력 출력이 웹 화면으로 돌아옵니다. 월별 달력 + 날짜별 일정 CRUD — 지금까지 배운 Spring MVC 구조를 한 번 더 처음부터 끝까지 반복하는 연습이에요.
Spring Boot
지금 쓰는 web.xml, servlet-context.xml, root-context.xml 같은 설정을 자동 설정으로 대체하고, 톰캣까지 안에 품고 있어 main() 실행 한 번으로 서버가 뜹니다. 설정은 application.properties 한 파일로. 개념은 지금 배운 Spring MVC 그대로입니다.
Thymeleaf
JSP를 대신하는 화면 템플릿. th:text, th:each처럼 HTML 속성으로 값을 끼워 넣어서, 서버 없이 열어도 HTML로 보이는 게 장점입니다. JSTL의 <c:forEach> ↔ th:each로 대응해 보면 금방 익숙해져요.
Spring Security
로그인·로그아웃, "관리자만 들어갈 수 있는 페이지" 같은 인증(누구냐)과 인가(무엇을 할 수 있냐)를 맡는 스프링 모듈. 내부적으로는 웹 과정에서 배운 Filter와 Session 위에서 동작합니다. 비밀번호는 반드시 암호화(BCrypt)해서 저장해요.
5계속 다시 나오는 큰 생각 5가지
과목 이름은 계속 바뀌었지만, 사실 같은 생각이 모양만 바꿔 반복됐습니다. 이 다섯 개를 잡으면 새 기술도 "아, 그거구나" 하고 이해돼요.
① 역할을 나눈다 (관심사 분리)한 곳이 모든 일을 하면 고치기 어렵다. 그래서 계속 쪼갰어요.
HTML(구조) / CSS(모양) / JS(동작)
클래스 하나 = 책임 하나
DTO / DAO → MVC1 → MVC2
Controller / Service / DAO / Mapper
② 약속(인터페이스)과 구현을 떼어 놓는다"무엇을 하느냐"와 "어떻게 하느냐"를 분리하면 갈아 끼울 수 있어요.
추상 클래스·인터페이스·다형성
JDBC 인터페이스 ↔ MariaDB 드라이버
스프링 DI — 구현체는 스프링이 넣어 준다
③ 반복되는 일은 도구에 맡긴다직접 해 보고 → 불편함을 느끼고 → 도구가 대신하는 순서로 배웠어요.
배열 → 컬렉션
JDBC 반복 코드 → MyBatis
Servlet 직접 작성 → DispatcherServlet
로그 코드 복붙 → AOP / XML 설정 → Spring Boot
④ 요청 → 처리 → 응답네트워크도, 웹도, 스프링도 결국 이 세 박자예요.
소켓: accept() → 읽기 → 쓰기
JS의 AJAX: 요청 보내고 응답 받기
Servlet: request → 처리 → response
forward / redirect = 응답을 어디로 보낼지
⑤ 데이터는 어디에, 언제까지 사나값을 "얼마나 오래, 누구와 공유할지"에 따라 담는 곳이 달라져요.
지역변수(블록) < 필드(객체) < static(프로그램)
request < session < application (scope)
쿠키·localStorage(브라우저) vs DB(영구)
트랜잭션 = DB에 "확정"하는 순간을 정하기
6비슷해서 헷갈리는 말 — 한 표로
면접에서 "A와 B의 차이는?" 형태로 정말 자주 나옵니다. 왼쪽 두 칸만 보고 차이를 말해 본 뒤 오른쪽을 확인하세요.
헷갈리는 쌍
한 줄 차이
기억 요령
JDK / JRE / JVM
개발 도구 포함 / 실행 환경 / 실제 실행기
JDK ⊃ JRE ⊃ JVM (큰 상자 안의 작은 상자)
오버로딩 / 오버라이딩
같은 이름 다른 매개변수 / 물려받은 메서드 재정의
로딩 = 옆으로 늘림, 라이딩 = 위에 덮어씀
추상 클래스 / 인터페이스
공통 코드 + 미완성 / 약속만 (다중 구현 가능)
"~이다(is-a)" vs "~할 수 있다(can-do)"
== / equals()
같은 객체(주소)인가 / 내용이 같은가
String은 무조건 equals
String / StringBuilder
못 바꿈(새 객체 생성) / 바꿀 수 있음
반복문에서 문자열 이어 붙이기는 StringBuilder
throw / throws
예외를 던진다 / 던질 수 있다고 선언
s가 붙으면 메서드 선언부에
WHERE / HAVING
묶기 전 행 필터 / 묶은 뒤 그룹 필터
집계 함수 조건이면 HAVING
DELETE / TRUNCATE / DROP
행 삭제 / 전체 비우기 / 표 자체 삭제
지우는 범위가 점점 커진다
INNER / OUTER JOIN
짝 있는 것만 / 짝 없어도 한쪽은 살림
"없는 것 찾기"는 OUTER + IS NULL
Web Server / WAS
정적 파일 전달 / 자바 코드를 실행해 동적 페이지 생성
톰캣 = WAS
forward / redirect
서버 안에서 넘김 / 브라우저가 다시 요청
저장 뒤엔 redirect (새로고침 중복 방지)
GET / POST
주소에 값을 붙여 조회 / 본문에 담아 변경
바꾸는 요청(저장·삭제)은 POST
세션 / 쿠키
서버에 저장 / 브라우저에 저장
로그인 정보는 세션
JSP / Servlet
HTML 안에 자바 / 자바 안에 HTML
JSP도 실행 전에 Servlet으로 바뀐다
Spring / Spring Boot
프레임워크 본체 / 자동 설정 + 내장 톰캣
Boot는 "스프링을 쉽게 시작하게 해 주는 것"
MyBatis / JPA
SQL을 직접 쓴다 / 객체를 다루면 SQL을 만들어 준다
지금 수업은 MyBatis
#{} / ${}
값으로 안전하게 바인딩 / 문자열 그대로 삽입
기본은 #, $는 인젝션 위험
7면접에서 "무엇을 배웠나요?"라고 물으면
아래는 이 과정을 30초로 말하는 예시입니다. 그대로 외우기보다 내 말로 바꿔서 연습하세요. 핵심은 "무엇을 만들었고, 왜 그 구조였는지"입니다.
"프런트엔드에서는 HTML·CSS·JavaScript로 화면을 만들고, React와 Next.js로 컴포넌트 기반 주식 대시보드를 완성했습니다. 백엔드에서는 자바 객체지향과 SQL을 익힌 뒤, JDBC와 JSP로 게시판을 직접 만들어 보고, 같은 게시판을 Servlet MVC2와 Spring MVC + MyBatis로 옮기면서 계층을 나누는 이유를 체감했습니다. 최근에는 답변형 게시판에 페이징과 답글을 구현하고, 답글 저장을 @Transactional로 묶어 데이터가 꼬이지 않게 했으며, AOP로 공통 로그를 분리했습니다."
💡 면접관이 이어서 물어볼 만한 것: "트랜잭션이 왜 필요했나요?", "MVC1에서 MVC2로 바꾸면서 뭐가 좋아졌나요?", "MyBatis를 쓰면 JDBC보다 뭐가 편한가요?" — 위 단계 카드의 면접 질문으로 준비할 수 있어요.
📘 개념서 — 처음부터 차례대로
자바 교안(PPT)과 실습과제, 그리고 수업에서 실제로 짠 코드를 한 권의 책으로 엮었습니다. 장마다 교안의 개념 → 그 개념을 익히려고 짠 수업 코드 → 핵심 정리 → 확인 문제 순서라서, 수학 개념서처럼 1장부터 차례대로 읽으면 됩니다.
한 장은 이렇게 생겼어요 — 🎯 이 장의 목표 → 🔗 왜 지금 배우나(앞 장과의 연결) → 개념(📎 교안 몇 쪽인지 표시) → 💻 수업 코드 예제("이 코드는 무엇을 보여 주려고 짠 것인지") → ⚠️ 교안 표현 바로잡기(있을 때만) → 핵심 정리 → 확인 문제 → 다음 장으로.
이렇게 공부하세요 — 확인 문제는 답을 펼치기 전에 먼저 소리 내어 답해 보세요. 막히면 그 장의 개념을 다시 읽고, 장 끝의 "이 장 다 읽었어요"를 누르면 목차에 ✓가 붙습니다(이 브라우저에만 저장). 전체 흐름이 궁금하면 🗺️ 큰 그림 탭을 먼저 보세요.
.java → (javac) → .class → (JVM) → 실행의 순서를 말할 수 있다.
JDK · JRE · JVM이 각각 무엇이고 서로 어떻게 포함되는지 설명할 수 있다.
클래스 · 메서드 · 변수 · 상수 · 패키지 이름을 명명법에 맞게 짓고, 쓸 수 없는 이름(식별자 규칙 위반)을 골라낼 수 있다.
주석 세 가지(//, /* */, /** */)를 구분해 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
첫 장입니다. 코드를 한 줄도 모르는 상태에서 가장 먼저 알아야 할 건 "내가 쓴 글자가 누구의 손을 거쳐 화면에 나오나"예요. 이 길을 알아야 앞으로 만날 빨간 에러가 컴파일할 때 난 것(문법이 틀림)인지 실행하다 난 것(실행 중 문제)인지 구분할 수 있습니다. 그리고 1일차 첫 코드 HelloJava는 사실 "자바 파일 한 개의 뼈대 + 이름 짓는 규칙"을 보여 주는 견본이에요.
개념
① JDK · JRE · JVM — 만드는 도구, 실행 환경, 가상 기계
게임에 비유하면 JVM은 게임기, JRE는 게임기 + 기본 부품(라이브러리), JDK는 거기에 게임을 만드는 도구(컴파일러 javac, 문서 생성기 javadoc 등)까지 든 개발 세트입니다. 그래서 포함 관계는 JDK ⊃ JRE ⊃ JVM. 우리 자바 프로그램은 운영체제와 직접 대화하지 않고 JVM하고만 대화해요. Windows용·Linux용 JVM이 따로 있어서, 같은 프로그램이 OS를 바꿔도 고치지 않고 돌아갑니다.
📎 교안 p.7~8 「JAVA의 기본 (JDK, JRE, JVM)」
② 컴파일 — 사람의 글을 JVM용 코드로 번역
우리가 쓴 Hello.java는 사람이 읽는 글이라 컴퓨터가 바로 못 읽어요. javac가 이것을 바이트코드(Hello.class)로 번역하는 것이 컴파일이고, 실행할 때 JVM이 바이트코드를 그 OS의 기계어로 바꿔 돌립니다. 교안처럼 명령창에서 하면 javac -d . Hello.java(패키지 경로대로 폴더를 만들며 컴파일) → java com.a.b.Hello(패키지 이름까지 붙여 실행) 두 단계예요. 우리 수업의 VS Code 설정은 java -sourcepath src 파일.java로 되어 있는데, 이것은 Java 11부터 생긴 "소스 파일을 바로 실행" 기능이라 컴파일과 실행이 한 번에 일어납니다.
📎 교안 p.9~11 「Compile」
③ 명명법과 식별자 규칙 — 이름 짓는 습관과 금지 사항
명명법은 "이렇게 지으면 읽기 좋다"는 약속입니다. 클래스는 Pascal(HelloJava), 변수·메서드는 camel(testMethod, isS), 상수는 모두 대문자(NUMBER, 단어 사이는 _), 패키지는 모두 소문자(hk.edu20260803.day01). 반면 식별자 규칙은 어기면 컴파일 에러가 나는 "법"이에요: ① 공백 금지 ② 특수문자는 _와 $만 ③ 숫자로 시작 금지(h4k O, 4hk X) ④ 예약어 금지(class, public, new …). 대소문자를 구분하므로 true는 못 쓰지만 True는 됩니다.
📎 교안 p.12 「명명법」 · p.13 「식별자」
④ 주석 — 실행되지 않는 설명문
// 한 줄 주석, /* 여러 줄 주석 */, 그리고 /** 문서 주석 */이 있어요. 앞의 둘은 컴파일러가 그냥 건너뛰고, 문서 주석은 javadoc이 읽어서 API 문서(HTML)를 만들어 줍니다. 수업 코드처럼 "이 줄이 왜 이런지"를 적어 두는 게 공부할 때 가장 큰 도움이 돼요.
📎 교안 p.14 「주석」
⚠️ 교안 표현 바로잡기
교안 p.7은 javap를 「클래스 파일을 원래의 소스로 변환」한다고 하지만, 정확히는 .class 안의 구조(필드·메서드 목록, -c를 주면 바이트코드)를 보여 주는 역어셈블러입니다. 원래 .java 소스로 되돌려 주지는 않아요. 또 p.13의 예약어 예시에 있는 java.lang.Object는 예약어가 아니라 클래스 이름입니다. p.3의 「JDK와 JRE가 패키지로 설치」는 Java 8까지의 이야기이고, 교안이 설치하는 Java 11부터는 JRE가 따로 설치되지 않고 JDK 안에 실행 환경이 함께 들어 있습니다.
예제 — 수업 코드로 확인하기
자바 파일 한 개의 뼈대 — HelloJava
이 코드는 자바 파일의 기본 골격(package → class → main)과 명명법을 한 화면에서 보여 주려고 만든 첫 실습입니다. 주석이 거의 "이름 짓기 규칙표" 역할을 하고 있어요.
JAVAHelloJava.java — 첫 프로그램
package hk.edu20260803.day01; //파일의 폴더 구조(경로), 최상단에 위치
//명명법
//클래스명: 파스칼
public class HelloJava {
// …(생략: main 메서드 각 단어 설명 주석)
// 메서드명: 카멜방식 (첫글자 소문자, 뒤 글자마다 대문자)
// 변수명: 카멜방식 (첫글자 소문자, 뒤 글자마다 대문자)
// 상수명: 스네이크 방식(모두 대문자)
public static final int NUMBER = 10000;
public int number = 10;
public static void main(String[] args) {
System.out.println("Hello Java");
testMethod();
}
// 메서드 선언: 카멜
public static void testMethod() {
// 변수명: 카멜
boolean isS = true;
int i = 100;
i = 200;
final int TEST = 10; // final: 상수로 선언됨. 변경 불가능
System.out.println("메서드 실행결과: " + i);
}
}
👀 관찰 포인트 — 실행하면 Hello Java, 메서드 실행결과: 200 두 줄이 나옵니다. ① 첫 줄 package hk.edu20260803.day01은 파일이 있는 폴더 src/hk/edu20260803/day01과 점 하나까지 대응합니다. ② public class HelloJava는 파일 이름 HelloJava.java와 같아야 해요. ③ 프로그램은 항상 main에서 시작해서, main이 부른 testMethod()로 넘어갑니다. ④ i에 100을 넣고 200을 넣었더니 마지막 값만 남았어요 — 다음 장 "변수"의 출발점입니다.
핵심 정리
실행 순서: .java(사람의 글) → javac 컴파일 → .class(바이트코드) → JVM이 OS에 맞게 실행.
JDK ⊃ JRE ⊃ JVM. 개발하려면 JDK, 실행만 하려면 실행 환경(JVM 포함)이 필요하다.
public 클래스 이름 = 파일 이름, package 선언 = 폴더 경로, 프로그램의 시작점 = main.
이름: 클래스 Pascal / 변수·메서드 camel / 상수 대문자 / 패키지 소문자. 숫자로 시작·공백·예약어는 금지.
확인 문제HelloJava.java 안의 public class HelloJava를 public class hellojava로 바꾸면?
컴파일 에러가 납니다. public 클래스는 파일 이름과 글자(대소문자 포함)가 같아야 하는데 파일은 HelloJava.java이기 때문이에요. 이름 자체는 식별자 규칙상 문제없지만 명명법(Pascal)에도 어긋납니다.
다음 중 변수 이름으로 쓸 수 없는 것은? 4hk, han kyung, _count, $price, class, True
4hk(숫자로 시작), han kyung(공백), class(예약어) 세 개입니다. _count·$price는 허용된 특수문자이고, True는 예약어 true와 대소문자가 달라서 됩니다(하지만 헷갈리니 실제로는 피하세요).
Windows에서 컴파일한 .class 파일을 Linux에서 실행하려면 다시 컴파일해야 하나요?
아니요. .class는 특정 OS가 아니라 JVM을 위한 바이트코드라서, Linux에 버전이 같거나 더 높은 자바 실행 환경만 있으면 그대로 실행됩니다. 이것이 JVM을 두는 이유예요.
다음 장으로
HelloJava에서 int i = 100;처럼 값을 담았어요. 그런데 int는 무엇이고, 왜 boolean·long처럼 종류가 여러 개일까요? 2장에서는 같은 1일차의 VariableTest로 변수와 기본타입, 형 변환을 배웁니다.
변수가 "이름 붙은 상자"이고, 블록 { } 안에서 만든 변수는 블록 밖에서 못 쓴다는 것을 안다.
기본타입 8가지의 크기를 알고, 정수 리터럴은 int, 실수 리터럴은 double이 기본이라 L·f를 붙이는 이유를 설명할 수 있다.
2의 보수로 byte의 범위가 왜 -128~127인지 설명할 수 있다.
자동 형 변환과 강제 형 변환(캐스팅)을 구분하고, byte + byte가 왜 int가 되는지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
1장에서 프로그램이 어떻게 실행되는지 봤으니, 이제 프로그램이 다루는 재료(값)를 볼 차례입니다. 자바는 값마다 상자 크기가 정해져 있어서, 큰 값을 작은 상자에 넣으면 넘치거나 잘려 나가요. 1일차 VariableTest는 바로 이 "상자 크기와 옮겨 담기"를 일부러 실험해 보는 코드입니다.
개념
① 변수와 블록 — 이름 붙은 상자, 쓸 수 있는 범위
변수는 값 하나를 넣어 두는 이름 붙은 상자예요. =는 "같다"가 아니라 오른쪽 값을 왼쪽 상자에 넣는다는 뜻이고, 여러 번 넣으면 마지막 값만 남습니다. 또 변수는 자신이 선언된 블록 { } 안에서만 살아 있어요. 바깥 블록의 변수는 안쪽에서 쓸 수 있지만, 안쪽(예: for문 안)에서 만든 변수는 블록이 끝나면 사라져서 바깥에서 쓸 수 없습니다.
📎 교안 p.15 「변수 · 블럭변수」
② 기본타입 8가지 — 상자의 크기
정수 byte(1바이트) · short(2) · int(4) · long(8), 실수 float(4) · double(8), 문자 char(2), 논리 boolean(true/false). 1바이트 = 8비트라서 byte는 2의 8제곱 = 256가지 값을 담아요. 코드에 그냥 쓴 숫자(리터럴)는 정수면 int, 실수면 double로 취급되므로, int 범위를 넘는 정수엔 L(1000000000000000000L), float 상자엔 f(15.77f)를 붙여 "이 크기로 봐 줘"라고 알려야 합니다.
📎 교안 p.16 「기본타입」 · p.21 「자료형의 크기, 범위, 초기값」
③ 2의 보수 — 음수를 비트로 쓰는 법
8비트의 맨 앞 칸은 부호 비트(0이면 양수, 1이면 음수)예요. 양수 127은 01111111. 그럼 -127은 부호만 1로 바꾼 11111111일까요? 아니에요(그건 -1). 자바는 2의 보수를 씁니다: 비트를 모두 뒤집고(1의 보수) 1을 더한다. 01111111 → 뒤집기 10000000 → +1 → 10000001 = -127. 이렇게 하면 127 + (-127)을 비트로 더했을 때 정확히 0이 됩니다. 남는 10000000은 -128이라서 byte의 범위는 -128 ~ 127(음수 쪽이 하나 더 많음)이고, 127에 1을 더하면 -128로 넘쳐서(overflow) 한 바퀴 돕니다.
📎 교안 p.17~18 「기본타입 & 참조타입 (2/4)」 · p.19~20 진법 변환
④ 형 변환 — 작은 상자 → 큰 상자는 자동, 반대는 강제
byte → int처럼 작은 타입을 큰 타입에 넣는 건 값이 안전하니 자동(업캐스팅)이에요. 반대로 int → byte는 넘칠 수 있어서 (byte)처럼 직접 써 줘야 하고(다운캐스팅), 넘치는 앞부분 비트는 잘려 나갑니다. 연산할 때도 규칙이 있어요: int보다 작은 정수(byte·short·char)끼리 연산하면 결과는 int, 정수와 실수를 섞으면 결과는 실수(int + double = double). 그 밖에 글자↔숫자 변환은 Integer.parseInt("9"), String.valueOf(9), (char)65 → 'A' 같은 표를 교안에서 확인하세요.
📎 교안 p.22~23 「기본타입 형 변환」 · p.24~25 변환표
⚠️ 교안·수업 코드 표현 바로잡기
교안 p.16은 기본타입이 「Stack에 생성」된다고 하지만, 그건 메서드 안의 지역변수일 때만입니다. 클래스의 필드(멤버필드)인 기본타입은 Heap의 객체 안에, static 필드는 Method Area에 있어요. p.21의 char 초기값 '\n0000'은 '\u0000'의 오타입니다. 수업 코드 주석도 두 군데 고쳐 읽으세요: byte bb = (byte) i; 옆 「byte -> int 다운캐스팅」은 int → byte이고, float f = 15.77f; 옆 「8byte크기라 f 붙여줌」은 float가 4바이트라서 — 즉 리터럴 15.77이 기본 double(8바이트)이므로 작은 float 상자에 넣으려면 f가 필요하다는 뜻입니다.
예제 — 수업 코드로 확인하기
정수 상자의 크기와 다운캐스팅
이 코드는 타입마다 담을 수 있는 범위가 다르고, 큰 값을 작은 상자에 억지로 넣으면 값이 깨진다는 것을 보여 주려고 만든 실습입니다.
JAVAVariableTest.java — 정수 타입
byte b = 1;
b = 127; // byte 표현범위는 -128 ~ 127
b = -128;
short sh = 128; // 2byte 크기
int i = 100000; // 4byte 크기
long l = 1000000000000000000L; // 리터럴 정수는 기본 int 형으로 인식함 (long타입 정수는 L을 붙여줘야함)
System.out.println("long타입 표현범위: " + l);
byte bb = (byte) i; // int -> byte 다운캐스팅, 원본값 손실됨 ← 원본 주석의 방향을 바로잡음
int ii = 126;
byte bbb = (byte) ii; // int -> byte 다운캐스팅, 원본값이 손실되지 않음
System.out.println("바이트 타입 표현범위: " + bbb);
👀 관찰 포인트 — 126은 byte 범위 안이라 bbb는 126 그대로예요. 코드에 System.out.println(bb);를 한 줄 더 넣어 보면 -96이 나옵니다. 100000을 2진수로 쓰면 …1 1000 0110 1010 0000인데, byte에는 마지막 8비트 10100000만 남아요. 맨 앞이 1이니 음수이고, 2의 보수로 크기를 구하면(뒤집기 01011111 +1 → 01100000 = 96) -96. 개념 ③이 실제로 일어난 거예요.
연산하면 타입이 바뀐다 — byte + byte = int
이 부분은 "연산 결과의 타입"이 따로 정해진다는 것을 보여 주려고, 일부러 에러가 나는 줄을 주석으로 남겨 둔 실습입니다.
JAVAVariableTest.java — 실수와 연산
// 2.실수타입
// 기본형은 double(8byte)
double d = 15.7;
float f = 15.77f;
float ff = (float) (d + f); // 큰값을 작은 상자에 담는다 (down casting)
// 3. 다른 타입끼리 연산
int iii = (int) (i + d); // int+double -> double, 연산결과가 double이므로 강제형변환 필요
// 4.정수끼리 연산
byte b1 = 10;
byte b2 = 20;
// byte b3 = b1 + b2; // …(생략: 왜 오류인지 설명하는 원본 주석)
byte b3 = (byte) (b1 + b2); // 연산의 결과값은 int로 반환되므로 강제형변환이 필요
byte b4 = 10 + 20; // 리터럴 정수는 기본 int 타입으로 인식하기 때문에 오류가 나지 않는다.
System.out.println("바이트 타입 연산 결과: " + b4);
👀 관찰 포인트 — 주석 처리된 byte b3 = b1 + b2;를 풀면 incompatible types: possible lossy conversion from int to byte 에러가 납니다. b1 + b2의 결과가 int이기 때문이에요. 그런데 byte b4 = 10 + 20;은 괜찮죠? 숫자만 있는 식은 컴파일러가 미리 30으로 계산해 보고 byte 범위 안이라 허락하지만, 변수끼리의 합은 실행 전에는 얼마일지 몰라서 막는 거예요. iii는 (int)100015.7 → 소수점이 버려져 100015가 됩니다(반올림 아님).
핵심 정리
변수 = 이름 붙은 상자, =는 오른쪽 → 왼쪽 대입, 블록 안에서 만든 변수는 블록 밖에서 못 쓴다.
정수 리터럴은 int, 실수 리터럴은 double이 기본 → long엔 L, float엔 f.
음수는 2의 보수(뒤집고 +1). 그래서 byte는 -128~127이고 넘치면 반대쪽 끝으로 돈다.
작은 → 큰 타입은 자동, 큰 → 작은 타입은 (타입) 강제 변환이며 값이 잘릴 수 있다. int보다 작은 정수끼리의 연산 결과는 int.
확인 문제byte b = 127; b++; 다음 b의 값은?
-128. 01111111에 1을 더하면 10000000이 되고, 2의 보수로 읽으면 -128이에요. (++와 +=는 결과를 원래 타입으로 알아서 되돌려 주기 때문에 컴파일 에러는 나지 않습니다.)
long l = 10000000000;은 왜 에러가 날까요?
숫자 리터럴은 기본이 int인데 100억은 int 범위(약 ±21억)를 넘기 때문입니다(integer number too large). 10000000000L처럼 L을 붙여 long 리터럴로 만들어야 해요.
-127을 8비트 2진수로 쓰면?
10000001. 127(01111111)을 뒤집으면 10000000, 여기에 1을 더하면 10000001입니다.
int ii = 300; byte x = (byte) ii;의 x는? (힌트: 300 = 256 + 44)
44. 300은 2진수 1 0010 1100이고 마지막 8비트 00101100만 남아 44가 됩니다. 맨 앞이 0이라 양수예요.
다음 장으로
값을 상자에 담을 줄 알게 됐으니, 이제 그 값으로 계산하고 비교할 차례입니다. 3장에서는 2일차 윤년 판별 코드로 연산자(나머지 %, 비교, &&·||, +=, 삼항)를 익힙니다.
&&·||·!로 조건을 묶고, short circuit(앞에서 결과가 정해지면 뒤를 안 봄)을 설명할 수 있다.
+= 같은 단축 대입과 i++ / ++i의 차이를 안다.
삼항 연산자 조건 ? A : B를 읽고 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
2장에서 값을 상자에 담았어요. 하지만 프로그램은 값을 담기만 하지 않고 계산하고, 비교해서 참/거짓을 만들고, 그 결과로 다음 행동을 정합니다. 연산자는 이 "참/거짓"을 만드는 도구라서, 4장의 if·for를 쓰려면 먼저 알아야 해요. 2일차 첫 실습인 윤년 판별은 연산자 여러 개를 한 줄에 조합하는 연습입니다.
개념
① 산술 · 관계 연산자 — 계산과 비교
+ - * /에 더해 %(나머지)가 중요해요. year % 4 == 0은 "4로 나눈 나머지가 0" = "4로 나누어떨어진다"입니다. 정수끼리 나누면 몫만 남아요(7 / 2는 3, 7 % 2는 1). 관계 연산자 > >= < <= == !=는 결과가 항상 true/false(boolean)입니다. =(대입)와 ==(같은가?)를 헷갈리지 마세요.
📎 교안 p.26 「관계연산자」 · p.32 「연산자 우선순위」
② 논리 연산자와 short circuit — 조건 묶기
&&(그리고)는 둘 다 참이어야 참, ||(또는)는 하나라도 참이면 참, !는 참/거짓을 뒤집어요. 그리고 &&는 앞이 false면, ||는 앞이 true면 뒤를 아예 계산하지 않습니다(이미 결과가 정해졌으니까). 이걸 short circuit이라고 해요. 덕분에 str != null && str.length() > 0처럼 "앞 조건이 안전을 보장해야 뒤를 실행"하는 코드를 쓸 수 있습니다. (&·| 한 개짜리는 양쪽을 무조건 다 계산해요.)
📎 교안 p.29~30 「논리연산자 · Short circuit」
③ 단축 대입 · 증감 연산자 — 줄여 쓰기
sum += i는 sum = sum + i의 줄임이에요(-=, *=, /=, %=도 같은 방식). i++는 1 증가인데, 식 안에서 쓰면 차이가 납니다: x++는 지금 값을 먼저 쓰고 나중에 증가, ++x는 먼저 증가하고 그 값을 사용. int x = 10; println(x++);은 10을 출력하고 x는 11이 돼요.
📎 교안 p.27 「단축연산자」 · p.28 「증감연산자」
④ 삼항 연산자와 우선순위
조건 ? A : B는 조건이 참이면 A, 거짓이면 B라는 값을 돌려주는 식이라 대입이나 출력 안에 바로 쓸 수 있어요. 여러 연산자가 섞이면 단항(++ !) → 산술(* / % 다음 + -) → 비교 → && → || → 삼항 → 대입 순서로 계산됩니다. 외우기보다 헷갈리면 괄호가 정답이에요.
📎 교안 p.31 「삼항연산자」 · p.32 「연산자 우선순위」
⚠️ 교안 표현 바로잡기
교안 p.30의 OR 설명은 「앞 조건식이 true일 때 : 실행 안함 → 뒤 조건식을 실행하지 않아도 결과는 무조건 false」라고 되어 있지만, ||는 하나만 참이어도 참이므로 결과는 무조건 true입니다(AND 설명을 복사하다 생긴 오타).
예제 — 수업 코드로 확인하기
윤년 판별 — 연산자 다섯 종류를 한 줄에
이 코드는 %, ==/!=, &&, ||, 괄호를 조합해 "말로 된 규칙"을 식 한 줄로 옮기는 연습으로 만든 실습입니다. 실습과제 PPT p.7의 문제 6(윤년·평년 판별)을 그대로 푼 코드예요.
JAVAD1_isLeapYear.java — 윤년 판별
// 윤년을 판단하는 조건을 확인
// -년도가 4의 배수이면서, 100으로 나누어떨어지지 않는 수
// -또는 400으로 나누어 떨어지는 수
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.print("년도를 입력해주세요 : ");
int year = sc.nextInt();
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {
System.out.println(year + "는 윤년입니다");
} else {
System.out.println(year + "는 평년입니다");
}
// …(생략: 2000~2030 윤년 출력 — 4장에서)
}
public static boolean isLeapYear(int year) {
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {
return true;
} else {
return false;
}
}
👀 관찰 포인트 — 세 해를 직접 따라가 보세요. 2026: 2026 % 4는 2 → ==0이 false라 && 뒤는 계산도 안 하고(short circuit) false, || 뒤 2026 % 400 == 0도 false → 평년. 1900: 4로는 나누어떨어지지만 100으로도 떨어져서 괄호 안 false, 400으로는 안 떨어져 → 평년. 2000: 괄호 안은 false지만 2000 % 400 == 0이 true → 윤년. 괄호는 사실 없어도 &&가 먼저 계산되어 결과가 같지만, "두 갈래 조건"이 눈에 보이게 묶어 둔 거예요.
단축 대입과 삼항 연산자가 실제로 쓰인 곳
ATM 메뉴는 잔고를 +=/-=로 누적하고, >로 출금 가능 여부를 비교하려고 만든 코드이고, 약수 출력은 삼항 연산자로 "마지막 숫자 뒤엔 쉼표를 안 찍는다"를 한 줄로 처리한 예입니다(약수 코드는 4일차 파일).
JAVAD2_ControlEx.java — ATM의 잔고 계산 (발췌)
case 1:
System.out.println("예금액 : ");
int saveTemp = Integer.parseInt(sc.nextLine());
savemoney += saveTemp;
break;
case 2:
System.out.println("출금액 : ");
int outTemp = Integer.parseInt(sc.nextLine());
if (outTemp > savemoney) {
System.out.println("잔고가 부족합니다");
} else {
savemoney -= outTemp;
}
break;
JAVAD1_divisor.java (4일차) — 약수 출력
public void divisor(int a) {
for (int i = 1; i < a + 1; i++) {
if (a % i == 0) {
System.out.print((i == a) ? i : i + ",");
}
}
System.out.println();
}
👀 관찰 포인트 — divisor(12)는 1,2,3,4,6,12를 출력합니다. i == a(마지막 약수인 12)일 때만 쉼표 없이 i를, 나머지는 i + ","를 돌려줘요. a % i == 0이 윤년에서 본 "나누어떨어진다"와 똑같은 식이라는 것도 확인하세요.
핵심 정리
a % b == 0 = "a는 b로 나누어떨어진다". 정수 나눗셈 /는 몫만 남긴다.
&&는 앞이 false면, ||는 앞이 true면 뒤를 계산하지 않는다(short circuit).
x += t = x = x + t. x++는 쓰고 나서 증가, ++x는 증가하고 나서 사용.
조건 ? A : B는 값을 돌려주는 식. 우선순위가 헷갈리면 괄호로 묶는다.
확인 문제int x = 10; int y = x++ + ++x; 실행 후 x와 y는?
x = 12, y = 22. x++는 10을 내놓고 x를 11로, 이어서 ++x가 x를 12로 만든 뒤 12를 내놓아 10 + 12 = 22입니다. (실무에서는 이렇게 섞어 쓰지 않는 게 좋아요.)
isLeapYear 메서드의 if~else를 한 줄로 줄이면?
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0; — 조건식 자체가 이미 true/false 값이므로 그대로 돌려주면 됩니다.
7 / 2, 7 % 2, 7.0 / 2의 결과는?
3, 1, 3.5. 정수끼리 나누면 몫(3)만, 한쪽이 실수면 2장에서 본 규칙대로 실수 연산이 됩니다.
다음 장으로
윤년 코드에서 이미 if ~ else와 for를 썼어요. 연산자가 만든 true/false로 갈 길을 고르고(조건문), 같은 일을 반복하는(반복문) 방법을 4장에서 정식으로 배웁니다.
갈림길이 둘이면 if ~ else, 값 하나로 여러 갈래면 switch ~ case를 고를 수 있다.
횟수를 알면 for, 모르면 while, 최소 한 번은 do ~ while을 쓴다는 기준을 안다.
break와 continue가 각각 어디로 빠져나가는지 말할 수 있다.
중첩 for에서 바깥 = 줄, 안쪽 = 그 줄의 개수로 생각하고, 개수를 등차수열 식으로 세울 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
지금까지의 코드는 위에서 아래로 한 줄씩 한 번만 실행됐어요. 3장에서 만든 true/false로 "어느 줄을 실행할지"(조건)와 "몇 번 실행할지"(반복)를 정하는 것이 제어문입니다. 2일차의 윤년 목록·ATM 메뉴와 3일차의 별 찍기는 모두 이 네 가지 도구(if, switch, for, while)의 조합이에요.
개념
① if ~ else — 두 갈래 길
if (조건) { A } else { B }: 조건이 참이면 A, 아니면 B. 괄호 안은 반드시 boolean이어야 해서 if (year % 4)처럼 숫자를 넣으면 에러예요. 갈래가 더 많으면 else if를 이어 붙입니다.
📎 교안 p.34 「if문」
② switch ~ case — 값 하나로 여러 갈래
엘리베이터 버튼처럼 값 하나(1, 2, 3, 4…)에 따라 갈 곳이 정해질 때 써요. 일치하는 case로 바로 가서 실행하고, break를 만나면 switch를 빠져나갑니다. break를 빼먹으면 아래 case까지 줄줄이 실행돼요(fall through). 어디에도 안 맞으면 default. 비교할 수 있는 값은 정수 계열(byte·short·int·char), enum, 그리고 Java 7부터 String입니다.
📎 교안 p.40 「switch ~ case문」
③ for · while · do ~ while — 반복의 세 가지 모양
for (초기값; 조건; 증감)은 몇 번 돌지 알 때(1~100까지, 2000~2030년까지). while (조건)은 언제 끝날지 모를 때(사용자가 "종료"를 고를 때까지, 주사위 합이 5가 될 때까지). do { } while (조건);은 조건을 나중에 검사해서 최소 한 번은 실행돼요.
📎 교안 p.33 「for문」 · p.35~36 「while, do~while」
④ break · continue — 반복 중간에 끊기
break는 가장 가까운 반복문(또는 switch)을 완전히 탈출, continue는 이번 바퀴의 나머지만 건너뛰고 다음 바퀴로 갑니다. 반복문이 겹쳐 있을 때 바깥까지 한 번에 빠져나가고 싶으면 라벨(aa:)을 붙이고 break aa;를 써요.
📎 교안 p.37~39 「break, continue」
⑤ 중첩 for와 등차수열 — 별 찍기의 공식
모양 출력은 공식이 하나예요: 바깥 for = 몇 번째 줄, 안쪽 for = 그 줄에 찍을 개수. 개수가 1, 3, 5, 7…처럼 일정하게 늘면 등차수열 aₙ = a₁ + (n-1)d로 식을 세웁니다. 첫째 항 1, 공차 2이고 줄 번호를 0부터 세는 i를 쓰면 개수 = 2*i + 1이 돼요.
📎 교안 p.41 「등차수열 & 등비수열」
⚠️ 교안 표현 바로잡기
교안 p.39의 라벨 예제 주석은 「break aa; 위치에서 aa:로 옮겨서 시작한다」고 하지만, break aa;는 라벨 위치로 되돌아가 다시 시작하는 게 아니라, 라벨이 붙은 블록 aa: { … }의 끝 바로 다음으로 빠져나갑니다. 그 예제에서는 그 뒤가 바깥 for의 끝이라서 결과적으로 다음 i로 넘어가는 것뿐이에요.
예제 — 수업 코드로 확인하기
반복문 안에 조건문 — 2000~2030년의 윤년
이 코드는 for(반복) 안에 if(조건)를 넣어 "범위에서 조건에 맞는 것만 골라내는" 가장 기본 조합을 보여 주려고 만든 실습입니다. 실습과제 PPT p.7 문제 7(2000년~현재까지 윤년만 출력)에 해당해요.
JAVAD1_isLeapYear.java — 윤년 목록
for (int i = 2000; i < 2031; i++) {
if ((i % 4 == 0 && i % 100 != 0) || i % 400 == 0) {
System.out.println(i + "는 윤년입니다");
}
}
👀 관찰 포인트 — 2000, 2004, 2008, 2012, 2016, 2020, 2024, 2028 여덟 줄이 나옵니다. for는 31번 돌지만 if를 통과한 해만 출력돼요. 3장의 조건식이 그대로 재사용됐다는 점 — 같은 식을 여러 번 쓰는 불편함은 6장 "메서드"에서 해결합니다.
while + switch — 종료를 고를 때까지 도는 ATM 메뉴
이 코드는 "언제 끝날지 모르는 반복은 while", "메뉴 번호로 갈래 나누기는 switch"를 한 프로그램에 합쳐 보려고 만든 실습입니다. 콘솔 프로그램의 기본 뼈대예요.
JAVAD2_ControlEx.java — ATM 메뉴 (발췌)
Scanner sc = new Scanner(System.in);
boolean start = true;
int savemoney = 0;
while (start) {
System.out.println("옵션을 선택하세요");
System.out.println("---------------------");
System.out.println("1.예금 2.출금 3.잔고 4.종료");
System.out.print("선택 >> ");
int select = Integer.parseInt(sc.nextLine());
switch (select) {
case 1:
// …(생략: 예금 — 3장 예제 참고)
break;
case 2:
// …(생략: 출금)
break;
case 3:
System.out.println("잔고 : " + savemoney);
break;
case 4:
System.out.println("종료합니다");
start = false;
break;
default:
System.out.println("잘못된 입력입니다");
break;
}
}
👀 관찰 포인트 — case 4에서 break만으로는 프로그램이 끝나지 않아요. switch 안의 break는 switch만 빠져나가기 때문입니다. 그래서 start = false로 while의 조건 자체를 꺼서, 다음 바퀴 검사 때 반복이 멈추게 했어요. 1~4가 아닌 숫자를 넣으면 default가 받습니다.
중첩 for — 별 찍기
3일차 별 찍기는 바깥 for = 줄, 안쪽 for = 개수라는 중첩 반복의 규칙과, 개수를 등차수열 식으로 세우는 법을 손에 익히려고 만든 실습입니다. 원본 파일 마지막 주석에도 「등차수열공식으로 하면 ++로 다 할 수 있음」이라고 적혀 있어요.
JAVAD1_StarView.java — 직각삼각형과 피라미드 (발췌)
for (int i = 0; i < 6; i++) {
for (int j = 0; j < i; j++) {
System.out.print("*");
}
System.out.println();
}
// …(생략: 다른 모양들)
int line = 5;
for (int i = 0; i < line; i++) {
for (int j = 0; j < line - 1 - i; j++) {
System.out.print(" ");
}
for (int k = 0; k < i * 2 + 1; k++) {
System.out.print("*");
}
System.out.println();
}
👀 관찰 포인트 — 첫 번째 모양은 i = 0일 때 별을 0개 찍어서 맨 위에 빈 줄이 하나 생기고, 그 아래로 *부터 *****까지 나옵니다. 피라미드는 줄마다 공백 line-1-i개(4, 3, 2, 1, 0) 다음 별 2i+1개(1, 3, 5, 7, 9)를 찍어 가운데 정렬된 삼각형이 돼요. 안쪽 for가 두 개(공백용, 별용)라는 점에 주목하세요.
핵심 정리
if의 괄호 안은 boolean만. 값 하나로 여러 갈래면 switch, 각 case 끝엔 break.
횟수를 알면 for, 모르면 while(+ 조건을 끄는 변수나 break), 최소 1번이면 do~while.
break = 가장 가까운 반복문/switch 탈출, continue = 이번 바퀴만 건너뜀.
중첩 for: 바깥 = 줄, 안쪽 = 줄마다의 개수. 개수는 등차수열 식(2*i + 1 등)으로 세운다.
확인 문제ATM 코드에서 case 1의 break를 지우면 1번(예금)을 골랐을 때 어떻게 되나요?
예금 처리 뒤에 멈추지 않고 case 2(출금)까지 이어서 실행됩니다(fall through). "출금액 : "이 뜨고 한 번 더 입력을 기다리게 돼요.
case 4에서 start = false;를 지우고 break;만 남기면?
"종료합니다"만 출력되고 메뉴가 다시 뜹니다. break는 switch만 빠져나가고 while의 조건 start는 여전히 true이기 때문이에요.
교안 p.36의 do~while 예제(i = 15에서 i++ 후 출력, 조건 i > 20)는 몇 번 출력될까요?
1번(i 값? 16). 조건 16 > 20은 처음부터 거짓이지만, do~while은 조건을 나중에 검사하므로 본문이 한 번은 실행됩니다.
피라미드를 7줄로 바꾸려면 코드에서 무엇만 고치면 되나요?
int line = 5;를 int line = 7;로. 공백 수와 별 수가 모두 line과 i로 된 식이라 나머지는 그대로 맞아떨어집니다. 이것이 식으로 세워 두는 장점이에요.
다음 장으로
지금까지 System.out.println, printf, Scanner를 설명 없이 써 왔어요. 1부의 마지막인 5장에서 출력 서식, escape 문자, 키보드 입력을 정리하고, Scanner의 유명한 함정도 피하는 법을 배웁니다.
print · println · printf의 차이를 알고, %d · %s · %f로 서식을 만들 수 있다.
\n · \t · \" · \\ 같은 escape 문자를 쓸 수 있다.
Scanner로 키보드 입력을 받고, nextInt() 뒤 nextLine() 함정과 그 해결법(Integer.parseInt(sc.nextLine()))을 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
4장의 ATM과 윤년 프로그램은 사용자에게 값을 받고(입력), 결과를 보여 줘야(출력) 의미가 있어요. 2일차 코드에는 같은 "숫자 입력받기"가 두 가지 방식으로 들어 있는데, 그 차이를 알면 입력이 이상하게 건너뛰어지는 흔한 버그를 피할 수 있습니다.
개념
① print · println · printf — 출력의 세 가지
print는 출력만, println은 출력 후 줄을 바꿉니다. printf는 "틀(서식) + 채울 값"으로 출력하는데, 줄을 자동으로 바꾸지 않아요. 틀 안의 빈칸은 %d(정수), %s(문자열), %f(실수 — float·double 모두), 소수 둘째 자리까지는 %.2f. 줄바꿈은 \n이나 %n을 직접 넣습니다. 예: printf("나이:%d 이름:%s%n", 30, "한경").
📎 교안 p.42 「print」
② escape 문자 — 문자열 안의 특수 기호
문자열은 "로 시작하고 끝나니, 그 안에 "를 글자로 넣으려면 \"라고 써야 해요. 이렇게 \ 뒤에 한 글자를 붙여 특별한 뜻을 내는 것이 escape 문자입니다. 자주 쓰는 것: \n(줄바꿈), \t(탭 — 칸 맞추기), \"(큰따옴표), \\(역슬래시 자체).
📎 교안 p.43 「escape」
③ Scanner — 키보드 입력 받기
import java.util.Scanner;로 가져오고 Scanner sc = new Scanner(System.in);으로 "키보드에 연결된 입력기"를 만듭니다. 그다음 sc.nextInt()(정수 하나), sc.nextDouble()(실수), sc.next()(공백 전까지 한 단어), sc.nextLine()(엔터까지 한 줄 전체)로 읽어요. 읽어 온 글자 "25"를 숫자로 바꿀 땐 2장 변환표의 Integer.parseInt("25")를 씁니다.
키보드로 25를 치고 엔터를 누르면 입력 통로에는 25와 줄바꿈 문자가 함께 들어가요. nextInt()는 숫자 25만 가져가고 줄바꿈은 남겨 둡니다. 그 다음 nextLine()은 남은 줄바꿈을 보고 "한 줄 끝!" 하며 빈 문자열을 돌려줘서, 입력할 기회가 없이 넘어간 것처럼 보여요. 가장 간단한 해결은 처음부터 모든 입력을 nextLine()으로 한 줄씩 읽고, 숫자가 필요하면 Integer.parseInt()로 바꾸는 것입니다.
교안 p.44의 import.java.util.scanner;는 그대로 치면 에러예요. 정확히는 import java.util.Scanner;(import 뒤는 점이 아니라 공백, 클래스 이름은 대문자 S)입니다. 또 InputStreamReader는 java.util이 아니라 java.io 패키지에 있어서 import java.io.InputStreamReader;로 가져와요. p.42의 %d : digit은 "10진 정수(decimal)", %f : float는 "실수(double도 됨)"로 이해하면 정확합니다.
예제 — 수업 코드로 확인하기
printf와 escape 문자로 구구단 출력
이 코드는 printf의 %d 자리에 계산 결과를 끼워 넣는 법과, \"·\t·\n으로 출력 모양을 다듬는 법을 보여 주려고 만든 실습입니다. 원본에서는 ATM을 시험하느라 주석(//)으로 꺼 둔 부분이라, 주석을 풀어야 실행돼요.
JAVAD2_ControlEx.java — 구구단 (원본에서 주석 처리된 부분을 풀어 둠)
System.out.print("========2단==========\n");
// 2단
for (int i = 1; i <= 9; i++) {
System.out.printf("\"2X%d=%d\"", i, 2 * i);
System.out.println();
}
System.out.print("=========2~9단==========\n");
// 2~9단
for (int i = 2; i <= 9; i++) {
for (int j = 1; j <= 9; j++) {
System.out.printf("%dx%d=%d", i, j, i * j);
System.out.print("\t");
}
System.out.println();
}
👀 관찰 포인트 — 2단은 "2X1=2"처럼 따옴표까지 글자로 찍혀요(\" 덕분). printf가 줄을 안 바꾸니 바로 뒤에 println()을 따로 불렀고, 제목 줄은 print에 \n을 넣어 줄을 바꿨습니다. 2~9단은 한 줄에 9개를 \t로 띄워 표처럼 정렬돼요. 4일차 약수 코드의 printf("%d와 %d는 친화수 관계입니다.\n", i, sumDivisor(i))도 같은 방식입니다.
같은 "숫자 입력", 두 가지 방식
2일차의 두 파일은 nextInt()로 바로 숫자를 받는 법과, nextLine()으로 한 줄을 받아 parseInt로 바꾸는 법을 각각 보여 줍니다. 입력이 한 번뿐인 윤년 판별은 앞의 방식, 메뉴를 계속 받아야 하는 ATM은 뒤의 방식을 썼어요.
JAVAD1_isLeapYear.java — nextInt()
import java.util.Scanner;
// …(생략)
Scanner sc = new Scanner(System.in);
System.out.print("년도를 입력해주세요 : ");
int year = sc.nextInt();
JAVAD2_ControlEx.java — nextLine() + parseInt
Scanner sc = new Scanner(System.in);
// …(생략)
System.out.print("선택 >> ");
int select = Integer.parseInt(sc.nextLine());
// …(생략)
System.out.println("예금액 : ");
int saveTemp = Integer.parseInt(sc.nextLine());
👀 관찰 포인트 — 입력 프롬프트는 print(줄 안 바꿈)라서 커서가 선택 >> 바로 옆에서 기다려요. ATM은 메뉴 번호 → 금액을 번갈아 여러 번 받는데, 모두 nextLine()으로 읽기 때문에 줄바꿈이 남지 않아 함정이 생기지 않습니다. 숫자가 아닌 글자(예: abc)를 넣으면 parseInt에서 NumberFormatException이 나는 것도 직접 확인해 보세요.
핵심 정리
print 줄 안 바꿈 / println 줄 바꿈 / printf 서식 출력(줄바꿈은 \n·%n을 직접).
%d 정수, %s 문자열, %f 실수, %.2f 소수 둘째 자리.
escape: \n 줄바꿈, \t 탭, \" 큰따옴표, \\ 역슬래시.
Scanner는 import java.util.Scanner;. 입력은 nextLine()으로 통일하고 숫자는 Integer.parseInt()로 바꾸면 줄바꿈 함정이 없다.
확인 문제System.out.printf("%d + %d = %d", 2, 3, 5);를 두 번 연속 실행하면 어떻게 보이나요?
2 + 3 = 52 + 3 = 5처럼 한 줄에 붙어 나옵니다. printf는 줄을 바꾸지 않으므로 서식 끝에 \n이나 %n을 넣어야 해요.
화면에 C:\temp를 출력하려고 println("C:\temp")라고 쓰면?
\t가 탭으로 해석되어 C: 뒤에 탭이 들어가고 emp가 찍힙니다. 역슬래시 자체를 쓰려면 "C:\\temp"처럼 두 번 써야 해요.
나이를 nextInt()로, 이어서 이름을 nextLine()으로 받았더니 이름을 입력할 틈 없이 빈 값이 됐어요. 왜죠?
nextInt()가 숫자만 가져가고 엔터(줄바꿈)를 남겨 두었고, nextLine()이 그 남은 줄바꿈을 "빈 한 줄"로 읽었기 때문입니다. 둘 다 nextLine()으로 받고 나이는 Integer.parseInt()로 바꾸면 해결돼요.
다음 장으로
1부가 끝났어요. 그런데 돌아보면 윤년 조건식을 한 파일에 세 번이나 똑같이 썼죠(입력 판별, for 목록, isLeapYear). 2부의 시작인 6장에서는 이렇게 반복되는 코드에 이름을 붙여 한 번만 쓰고 여러 번 부르는 방법, 메서드를 배웁니다.
static 메서드는 클래스명.메서드(), 인스턴스 메서드는 객체.메서드()로 부르는 이유를 설명할 수 있다.
오버로딩이 성립하는 조건을 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
1부의 코드는 거의 모두 main 하나에 몰려 있었고, 윤년 조건처럼 같은 식을 여러 번 복사했어요. 사실 D1_isLeapYear에는 isLeapYear() 메서드가 이미 있는데 main이 한 번도 부르지 않습니다. 메서드는 "코드 묶음에 이름을 붙여 두고 필요할 때 이름으로 부르는 것"이고, 객체지향(2부)의 가장 작은 단위예요. 3일차 D2_MethodTest는 메서드의 종류를, 4일차 D1_divisor는 실제로 메서드를 나눠 문제를 푸는 법을 보여 줍니다.
개념
① 메서드의 생김새 — 자판기
메서드는 자판기와 같아요. 동전(매개변수)을 넣으면 → 안에서 정해진 일을 하고 → 음료(반환값)를 내놓습니다. public static int getGcd(int a, int b) { … }를 읽으면: 누구나 쓸 수 있고(public), 객체 없이 부를 수 있고(static), int를 돌려주며(int), 이름은 getGcd, int 두 개를 받는다. 메서드는 반드시 클래스 안에 만들고, 메서드 안에 또 메서드를 만들 수는 없어요.
📎 교안 p.54 「메소드」
② 반환타입과 return
돌려줄 게 없으면 void. 반환타입을 적었다면 어떤 길로 끝나든 반드시 그 타입의 값을 return해야 해요(빠지면 컴파일 에러). return을 만나면 메서드는 그 자리에서 끝나고 값을 부른 쪽에 넘깁니다. 부른 쪽은 보통 같은 타입의 변수에 받아요: int g = getGcd(12, 18);
📎 교안 p.57 「return type」
③ 매개변수와 인수
선언 쪽 (int a, int b)의 a, b는 매개변수(parameter) — 값을 받을 빈 상자예요. 부르는 쪽 getGcd(12, 18)의 12, 18은 인수(argument) — 실제로 넣는 값입니다. 호출되는 순간 인수의 값이 매개변수에 복사되고, 매개변수는 메서드 안에서만 사는 지역변수예요. (참조타입을 넘길 때 무슨 일이 생기는지는 9장에서.)
📎 교안 p.58 「parameter & argument」
④ static 메서드 vs 인스턴스 메서드
static이 붙은 메서드는 클래스에 딸린 것이라 클래스명.메서드()로 바로 부르고, 붙지 않은 메서드는 객체(인스턴스)마다 딸린 것이라 new로 객체를 만든 뒤 객체.메서드()로 불러요. 그래서 static인 main 안에서 인스턴스 메서드를 그냥 부르면 "누구의 메서드를 부르라는 거야?"가 되어 에러가 납니다. 해결은 두 가지: 객체를 만들어 부르거나, 그 메서드도 static으로 만들거나.
📎 교안 p.56 「static & non-static」
⑤ 오버로딩 — 같은 이름, 다른 매개변수
하는 일이 비슷하면 이름을 같게 짓고 매개변수의 개수나 타입을 다르게 해서 여러 개 만들 수 있어요. 우리가 매일 쓰는 println도 println(int), println(String) … 여러 개가 오버로딩된 거라 무엇을 넣어도 찍힙니다. 단, 반환타입만 다른 건 오버로딩이 아니에요(부르는 쪽에서 구분이 안 되니까). 실제 수업 코드의 오버로딩은 7장의 생성자에서 봅니다.
📎 교안 p.59 「Overloading」
⚠️ 수업 코드 주석·교안 바로잡기
D2_MethodTest의 주석 「test02()는 메모리에 올라가 있지 않음」(1장 HelloJava의 비슷한 주석도)은 정확하지 않아요. static 메서드에서 인스턴스 메서드를 바로 못 부르는 이유는 메모리 때문이 아니라 호출할 객체(this)가 없어서입니다. 그래서 new D2_MethodTest()로 객체를 하나 만들어 주면 바로 부를 수 있죠. 또 test03() 위 주석 「매개변수 없음, 반환값 없음」은 아래 메서드와 맞지 않아요 — test03()은 매개변수 없음, 반환값 있음(int)입니다. 교안 p.58의 「pass by reference」 표현은 9장에서 바로잡습니다(자바는 항상 값 전달).
예제 — 수업 코드로 확인하기
메서드의 유형 — static / non-static, 매개변수 · 반환값 유무
이 코드는 메서드를 "static이냐 아니냐", "받는 게 있냐", "돌려주는 게 있냐"로 분류하고, 종류별로 부르는 법을 보여 주려고 만든 실습입니다.
JAVAD2_MethodTest.java — 메서드의 유형
public static void main(String[] args) {
// static 메서드 사용: 클래스명.메서드() 호출해서 사용
D2_MethodTest.test01();
// non-static 메서드 사용: 객체생성후 메서드 호출
D2_MethodTest methodTest = new D2_MethodTest();
methodTest.test02();
}
public static void test01() {
System.out.println("static 메서드");
// …(생략: "메모리에 올라가 있지 않음" 주석 — 위 바로잡기 참고)
D2_MethodTest methodTest = new D2_MethodTest();
methodTest.test02();
}
// non-static 메서드 -> 객체생성해야지만 사용 가능
public void test02() {
System.out.println("non-static 메서드");
}
public int test03() { // 매개변수 X, 반환값 O
return 0;// 반환타입을 설정했다면 반드시 해당 타입을 반환
}
public void test04() { // 매개변수 X, 반환값 X
}
// 3. 파라미터 O/X : 외부로부터 값을 받아서 뭔가 실행하려고
public int test05(int a, int b) { // 매개변수 O, 반환값 O
int res = 0;
if (a > b) {
res = a;
} else {
res = b;
}
return res;
}
👀 관찰 포인트 — 출력은 static 메서드 → non-static 메서드 → non-static 메서드 세 줄입니다. test01(static) 안에서도 test02를 부르려면 객체를 먼저 만들었다는 게 핵심이에요. test05는 두 수 중 큰 값을 돌려주는데, res를 0으로 먼저 초기화한 뒤 if/else 어느 길로 가든 return res; 한 곳으로 모이게 만든 모양을 눈여겨보세요.
메서드로 나눠 풀기 — 약수 · 최대공약수 · 최소공배수 · 친화수 · 완전수
이 코드는 실습과제 PPT p.2~6의 문제 1~5를 한 클래스에서 푼 것으로, 작은 메서드를 만들어 두고 다른 메서드가 그것을 재사용하는 법을 보여 줍니다(4일차 파일).
JAVAD1_divisor.java — 메서드 조합 (발췌)
public static void main(String[] args) {
D1_divisor d = new D1_divisor();
d.divisor(12);
System.out.println("최대공약수1:" + getGcd1(12, 18));
System.out.println("최대공약수2:" + getGcd2(12, 18));
lowestMultiple(12, 18);
// amicable은 non-static메서드이기떄문에 객체생성해서 객체명.메서드로 호출한다.
d.amicable(1, 1000);
d.perfectnum(1, 1000);
}
// 유클리드 호제법 재귀호출
public static int getGcd2(int a, int b) {
if (b == 0) {
return a;
}
return getGcd2(b, a % b);
}
// 최소공배수
public static void lowestMultiple(int a, int b) {
int gcd = getGcd1(a, b);
int lowestMultiple = (a * b) / gcd;
System.out.println("최소공배수:" + lowestMultiple);
}
// 진약수 합
public static int sumDivisor(int a) {
int sum = 0;
for (int i = 1; i < a; i++) {
if (a % i == 0) {
sum += i;
}
}
return sum;
}
// 친화수
public void amicable(int s, int e) {
for (int i = s; i <= e; i++) {
if (i != sumDivisor(i) && i == sumDivisor(sumDivisor(i))) {
System.out.printf("%d와 %d는 친화수 관계입니다.\n", i, sumDivisor(i));
}
}
}
// 완전수
public void perfectnum(int s, int e) {
for (int i = s; i <= e; i++) {
if (i == sumDivisor(i)) {
System.out.println(i + "는 완전수입니다.");
}
}
}
👀 관찰 포인트 — 출력: 1,2,3,4,6,12 / 최대공약수1:6 / 최대공약수2:6 / 최소공배수:36 / 220과 284 친화수(두 번, 순서 바꿔서) / 6, 28, 496 완전수. ① sumDivisor 하나를 만들어 두니 친화수와 완전수가 한 줄짜리 조건으로 끝났어요 — 이게 메서드로 나누는 이유입니다. ② getGcd2는 자기 자신을 다시 부르는 재귀: (12,18) → (18,12) → (12,6) → (6,0) → 6. ③ static인 getGcd1은 이름만으로, 인스턴스 메서드인 divisor·amicable은 객체 d를 통해 불렀어요(개념 ④).
핵심 정리
메서드 = 이름 붙은 코드 묶음. 반환타입 이름(매개변수), 반드시 클래스 안에.
반환타입이 있으면 모든 경로에서 return 값;, 없으면 void.
매개변수 = 받는 빈 상자(선언 쪽), 인수 = 넣는 실제 값(호출 쪽). 호출 때 값이 복사된다.
static → 클래스명.메서드(), 인스턴스 → 객체.메서드(). static 안에서 인스턴스 메서드를 바로 못 부르는 건 객체(this)가 없어서.
main 안에서 객체 없이 test02();라고만 쓰면 어떻게 되고, 고치는 방법 두 가지는?
non-static method test02() cannot be referenced from a static context 에러. ① new D2_MethodTest().test02();처럼 객체를 만들어 부르거나 ② test02를 public static void로 바꿉니다.
int sum(int a, int b)와 long sum(int a, int b)를 한 클래스에 같이 만들 수 있나요?
없습니다. 매개변수(개수·타입)가 똑같고 반환타입만 달라서, sum(1, 2)를 부를 때 어느 것인지 구분할 수 없어요. 오버로딩은 매개변수가 달라야 합니다.
윤년 코드의 main을 isLeapYear를 쓰도록 고치면 if 줄은 어떻게 되나요?
if (isLeapYear(year)) { … }. 둘 다 static이라 같은 클래스 안에서는 이름만으로 부를 수 있고, 조건식은 메서드 안에 한 번만 있으면 됩니다.
다음 장으로
인스턴스 메서드를 부르려고 new D2_MethodTest()로 "객체"를 만들었어요. 그 객체는 정확히 무엇이고, new 할 때 무슨 일이 일어날까요? 7장에서 클래스 · 객체 · 생성자를 배웁니다.
클래스(설계도)와 객체(실물)의 관계, A a = new A();의 각 부분을 설명할 수 있다.
인스턴스 변수는 객체마다 따로, static 변수는 클래스에 하나라는 것을 코드 결과로 확인할 수 있다.
생성자의 규칙(이름 = 클래스명, 반환타입 없음, 기본 생성자가 자동으로 생기는 조건)을 안다.
this.필드와 this(…)의 쓰임을 구분하고, 생성자 오버로딩을 만들 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
6장에서 인스턴스 메서드를 부르려고 객체를 만들었어요. 객체지향은 "데이터(필드)와 그 데이터를 다루는 기능(메서드)을 한 덩어리로 묶은 객체"로 프로그램을 짜는 방식입니다. 4일차 코드는 TV 같은 물건을 클래스로 설계하고, 생성자로 "처음부터 원하는 상태로" 만들어 보는 실습이에요.
개념
① 클래스와 객체 — 붕어빵 틀과 붕어빵
클래스는 붕어빵 틀(설계도)이고, 객체(인스턴스)는 그 틀로 찍어 낸 붕어빵이에요. 틀 하나로 붕어빵을 여러 개 만들 수 있고, 각각 속(필드 값)이 다를 수 있습니다. D2_ClassTest classTest = new D2_ClassTest();를 나눠 읽으면: 왼쪽 D2_ClassTest는 변수의 타입(클래스 = 사용자가 만든 타입), classTest는 변수 이름, new는 Heap 메모리에 객체를 새로 만들고, D2_ClassTest()는 생성자 호출. 변수에는 객체 자체가 아니라 객체가 있는 곳(참조)이 담겨요.
📎 교안 p.47~48 「클래스 & 객체 & 메소드」
② 클래스 안의 용어 — 필드 · 지역변수 · 매개변수
클래스 바로 아래 선언한 변수가 멤버필드예요. 그중 static이 없는 것은 인스턴스 변수(객체마다 하나씩, 객체가 사라질 때까지 유지), static이 붙은 것은 클래스 변수(클래스에 딱 하나, 모든 객체가 공유). 메서드 안에서 만든 변수는 지역변수, 메서드 괄호 안의 것은 매개변수로, 메서드가 끝나면 사라집니다.
📎 교안 p.46 「클래스에서의 용어」
③ 생성자 — 객체가 태어날 때 한 번 실행
생성자는 new로 객체를 만들 때 딱 한 번 실행되는 초기화 코드예요. 생김새는 메서드와 비슷하지만 이름이 클래스 이름과 같고 반환타입이 없습니다(void도 안 씀). 생성자를 하나도 안 쓰면 컴파일러가 빈 기본 생성자 클래스명() { }를 넣어 주지만, 하나라도 직접 쓰면 넣어 주지 않아요. 그리고 모든 생성자의 첫 줄에는 부모 생성자 호출 super();가 숨어 있습니다(4일차 D1_divisor 생성자에 직접 써 둔 그 줄 — 부모는 Object).
📎 교안 p.60 「Constructor(생성자)」
④ 생성자 오버로딩과 this
"기본 TV", "70인치 TV", "60인치 노랑 TV"처럼 만드는 방법이 여러 가지면 생성자를 매개변수만 다르게 여러 개 만들어요(6장의 오버로딩). 이때 this가 두 가지로 쓰입니다. this.size = size;의 this.는 "이 객체의 필드 size"라는 뜻으로 같은 이름의 매개변수와 구분해 주고, this(24, "검정색");의 this(…)는 같은 클래스의 다른 생성자를 부르는 것으로 생성자의 첫 줄에만 쓸 수 있어요. 교안 p.62처럼 이름 없는 { }(인스턴스 블록)는 생성자보다 먼저 실행됩니다.
📎 교안 p.61~63 「생성자 오버로딩」
⚠️ 교안 표현 바로잡기
교안 p.60은 this( );를 「자식 생성자 호출」이라고 하지만, 정확히는 같은 클래스 안의 다른 생성자 호출입니다(부모의 것은 super( )). 또 「user define class 생성 시 자동으로 생성(default 생성자)」은 생성자를 하나도 쓰지 않았을 때만 맞아요. 수업 코드 주석 「파라미터가 있는 생성자를 추가하면 default생성자 생략 못함」이 바로 이 뜻입니다.
예제 — 수업 코드로 확인하기
인스턴스 변수 vs static 변수 — 객체 두 개로 비교
이 두 파일은 객체를 두 개 만들어서, 인스턴스 변수는 객체마다 따로 · static 변수는 모두가 공유한다는 것을 출력으로 확인하려고 만든 실습입니다. 설계도(D2_ClassTest)와 사용하는 쪽(D2_ClassTestMain)을 파일로 나눈 것도 처음이에요.
JAVAD2_ClassTest.java — 설계도
public class D2_ClassTest {
public int number; // 인스턴스 멤버필드 (변수)
public static int staticNumber; // 클래스 변수
// …(생략: 기본 생성자 설명 주석)
public D2_ClassTest() {
this(10); // 자기 자신의 (다른) 생성자 호출
}
// 생성자 오버로딩: 파라미터의 개수와 타입을 다르게 해서 생성자나 메서드 이름을 같게 사용
public D2_ClassTest(int number) {
this.number = number; // this->이 객체 자신을 의미. 멤버필드 number를 지칭
}
public void methodTest() {
System.out.println("인스턴스에 관련된 기능을 정의한다");
}
public static void stmethodTest() {
System.out.println("메서드영역 메모리에 생성되어 공통기능을 정의한다.");
}
}
👀 관찰 포인트 — number는 20과 40으로 객체마다 다르고, staticNumber는 한 번만 50을 넣었는데 두 객체 모두 50이에요(칸이 하나뿐이니까). new D2_ClassTest()는 기본 생성자 → this(10) → number = 10으로 시작했다가 20으로 바뀐 것이고요. classTest.staticNumber처럼 객체 이름으로도 static 변수에 닿긴 하지만, 공유된다는 사실이 가려지므로 D2_ClassTest.staticNumber처럼 클래스 이름으로 쓰는 게 바른 습관입니다.
TV 만들기 — 생성자 오버로딩과 this(…)
이 코드는 교안 p.62~63의 Television 예제를 수업에서 직접 다시 짠 것으로, 만드는 방법(생성자)을 여러 개 두고, 기본 생성자가 this(…)로 다른 생성자에게 일을 넘기는 법을 보여 줍니다.
JAVAD4_Constructor.java — TV 설계도 (생성자 부분)
public class D4_Constructor {
private int size = 0; // 중요한 데이터 -->private 선언
public String color = "검정석"; // 색상 ← 원본 오타 그대로
// default 생성자: 단독으로 사용한다면 생략 가능 -> 오버로딩을 하면 생략 못함
public D4_Constructor() {
this(24, "검정색"); // 생성자 호출은 반드시 첫줄에 작성
}
// 생성자 오버로딩
public D4_Constructor(int size) {
this.size = size;
}
public D4_Constructor(int size, String color) {
this.size = size;
this.color = color;
}
// …(생략: getSize / setSize — 8장에서)
}
JAVAD1_ConstructioMain.java — TV 네 대 만들기
D4_Constructor tv = new D4_Constructor();
D4_Constructor tv2 = new D4_Constructor(70);
D4_Constructor tv3 = new D4_Constructor(60, "노랑색");
// 생성자 이용 안하고, 직접 멤버필드에 접근해서 초기화 할경우 불편하다
D4_Constructor tv4 = new D4_Constructor();
tv4.color = "파란색";
tv4.setSize(24); // private이라 메서드 통해 값 추가
👀 관찰 포인트 — 만들어진 TV를 정리하면 tv = 24인치 검정색, tv2 = 70인치 "검정석", tv3 = 60인치 노랑색, tv4 = 24인치 파란색입니다. tv2의 색이 이상하죠? D4_Constructor(int size)는 color를 건드리지 않아서 필드 선언 때의 초기값(원본의 오타 "검정석")이 그대로 남은 거예요. 필드 초기값 → 생성자 순서로 값이 정해진다는 걸 보여 주는 좋은 단서입니다. tv4처럼 만든 뒤에 하나씩 넣는 것보다 생성자로 한 번에 만드는 게 편하다는 것이 이 실습의 결론이에요.
핵심 정리
클래스 = 설계도(타입), 객체 = new로 Heap에 만든 실물. 변수에는 객체의 참조가 담긴다.
생성자: 이름 = 클래스명, 반환타입 없음, new 할 때 한 번. 하나도 안 쓰면 기본 생성자가 자동, 하나라도 쓰면 자동 없음.
this.필드 = 이 객체의 필드, this(…) = 같은 클래스의 다른 생성자(첫 줄에만).
확인 문제D4_Constructor에서 매개변수 없는 생성자를 지우면 D1_ConstructioMain의 어느 줄이 에러가 날까요?
new D4_Constructor()를 쓰는 tv와 tv4 줄입니다. 다른 생성자가 두 개 있으니 컴파일러가 기본 생성자를 자동으로 넣어 주지 않아요.
기본 생성자에서 this(24, "검정색"); 위에 System.out.println("생산 시작");을 넣으면?
컴파일 에러입니다(Java 21 기준). this(…)·super(…) 호출은 생성자의 첫 문장이어야 해요. 원본 주석 「생성자 호출은 반드시 첫줄에 작성」이 이 규칙입니다.
this.number = number;에서 this.를 빼고 number = number;라고 쓰면 필드 number는?
바뀌지 않습니다(그대로 0). 메서드 안에서 number는 더 가까운 매개변수를 가리키므로 매개변수에 자기 자신을 대입한 꼴이 돼요. 필드를 가리키려면 this.가 필요합니다.
다음 장으로
TV의 size는 private이라 tv4.size = 24로 못 넣고 setSize()를 썼어요. 또 생성자 첫 줄의 숨은 super()는 부모 Object를 부른다고 했죠. 8장에서는 Object 클래스, 접근 제한자, static, package를 묶어서 정리합니다.
모든 클래스의 부모가 Object이고, 그래서 어떤 객체든 getClass()·toString()·hashCode()·equals()를 갖는다는 것을 안다.
==(같은 객체인가)와 equals()(내용이 같은가 — 클래스가 정한 기준)를 구분할 수 있다.
접근 제한자 4가지의 범위를 말하고, private 필드 + getter/setter(캡슐화)를 쓸 수 있다.
static 멤버가 메모리 어디에 몇 개 있는지 설명하고, package·import 순서를 지켜 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
7장에서 클래스를 만들고 객체를 찍어 냈어요. 이제 그 클래스가 누구에게서 무엇을 물려받고(Object), 누구에게 얼마나 보여 줄지(접근 제한자), 무엇을 모두가 함께 쓸지(static), 어느 폴더에 둘지(package)를 정할 차례입니다. 4일차의 D3_ObjectTest는 Object의 메서드를 하나씩 찍어 보는 실습이고, 7장의 TV 코드는 private 필드를 다루는 법을 함께 담고 있어요.
개념
① Object — 모든 클래스의 조상
extends를 안 써도 모든 클래스는 자동으로 java.lang.Object를 물려받아요. 그래서 우리가 만든 아무 클래스의 객체에도 이 메서드들이 있습니다. getClass() → 실제 클래스(class 패키지.클래스명), toString() → 객체를 글자로(기본은 패키지.클래스명@해시코드16진수), hashCode() → 해시 자료구조(HashMap 등)가 쓰는 int 값, equals(o) → 두 객체가 "같은지". 부모 타입 변수에 자식 객체를 담는 것도 자동이에요(Object obj = sm;). 다만 그러면 Object의 메서드만 보이고, 원래 메서드를 쓰려면 (D3_Singleton) obj처럼 다시 형 변환합니다.
📎 교안 p.49~53 「Object 클래스」
② == 와 equals()
==는 참조타입에서 두 변수가 같은 객체를 가리키는가(주소가 같은가)를 봐요. equals()는 클래스가 정한 "같다"의 기준으로 비교하는데, Object의 기본 equals는 그냥 ==와 똑같습니다. String은 equals를 재정의해서 글자 내용을 비교하므로, 문자열 비교는 항상 equals를 써야 해요. 내가 만든 클래스도 내용으로 비교하고 싶으면 equals(와 짝인 hashCode)를 재정의해야 합니다(상속·오버라이딩 장에서).
📎 교안 p.52~53 「hashcode · equals」
③ 접근 제한자 — 누구에게 보여 줄까
넓은 순서로 public(어디서나) > protected(같은 패키지 + 다른 패키지라도 자식 클래스) > (default, 아무것도 안 씀)(같은 패키지만) > private(그 클래스 안에서만). 중요한 데이터는 private로 숨기고, 꼭 필요한 통로만 public 메서드(getter/setter)로 열어 두는 것을 캡슐화라고 해요. 통로가 메서드이니 "비밀번호가 맞을 때만 알려 준다" 같은 검사를 끼워 넣을 수 있습니다.
📎 교안 p.55 「접근 제한자」
④ static — 클래스에 하나, 모두가 공유
static 필드·메서드는 클래스가 처음 쓰일 때 Method Area에 딱 하나 만들어져 프로그램 끝까지 남아요. 인스턴스 필드는 new 할 때마다 Heap의 객체 안에 하나씩 생깁니다. 그래서 static 메서드 안에서는 this(지금 이 객체)가 없어 인스턴스 필드·메서드를 바로 쓸 수 없고, 반대로 인스턴스 메서드는 static을 자유롭게 씁니다. 메서드 안의 지역변수·매개변수는 Stack에 생겼다가 메서드가 끝나면 사라져요.
📎 교안 p.56 「static & non-static」
⑤ package와 import — 이름 = 폴더 경로
패키지는 클래스를 담는 폴더이자 이름의 성(姓)이에요. hk.edu20260806.day04.D3_ObjectTest가 이 클래스의 정식 이름입니다. 파일 순서는 package 한 줄 → import 여러 줄 → 클래스. 다른 패키지의 클래스는 import java.util.Scanner;처럼 가져와야 하지만, String·Object·System·Math·Integer가 있는 java.lang만은 import 없이 쓸 수 있어요.
📎 교안 p.64 「package & import」
⚠️ 교안·수업 코드 표현 바로잡기
D3_ObjectTest의 주석 「equals()가 hashcode()를 이용해서 객체를 비교한다」, 「hashcode로 비교」와 교안 p.52 「hashcode는 객체의 고유한 판별 값」은 정확하지 않아요. Object의 기본 equals는 ==(같은 객체인가)이고, String의 equals는 글자를 하나씩 비교합니다. hashCode는 HashMap 같은 해시 자료구조가 쓰는 보조값이라 고유하지 않아요 — 다른 객체가 같은 값을 가질 수도 있습니다(규칙은 "equals가 true면 hashCode도 같아야 한다" 한 방향뿐). 또 교안 p.55의 protected 「상속일 경우 어디서나 접근 가능(public)」은, 다른 패키지에서는 자식 클래스 안에서 물려받은 멤버로만 쓸 수 있다는 뜻으로 읽어야 합니다.
예제 — 수업 코드로 확인하기
Object의 4대 메서드와 == / equals 찍어 보기
이 코드는 아무 메서드도 만들지 않은 클래스(D3_ObjectTest)에서도 Object에게 물려받은 메서드를 쓸 수 있다는 것, 그리고 문자열을 ==와 equals로 비교하면 결과가 달라질 수 있다는 것을 보여 주려고 만든 실습입니다.
JAVAD3_ObjectTest.java — Object 메서드
String str = new String("Object");
String str2 = "ObjectLit"; // 주로 사용되는 방식
System.out.println(str.getClass());
System.out.println(str2.getClass());
D3_ObjectTest ot = new D3_ObjectTest();
System.out.println(ot.getClass());
// toString(): 문자열로 반환한다
// target 객체에 "위치@hashcode(16진수)" 반환
System.out.println(ot.toString());
System.out.println(str.toString());
// hashcode(): 객체의 hashcode를 반환한다. -> 10진수로 표현
System.out.println(ot.hashCode());
// …(생략: equals·hashCode 관계 주석 — 위 바로잡기 참고)
System.out.println(ot.equals(str2));
// 리터럴 방식 선언(String)
String s = "a";
String s2 = "a";
System.out.println(s == s2); // 비교연산자: 객체에 주소로 비교
System.out.println(s.equals(s2)); // String.equals: 글자 내용 비교 (원본 주석 "hashcode로 비교"를 바로잡음)
// 객체 생성으로 선언(new String())
String s3 = new String("a");
System.out.println(s == s3); // false //메모리 주소가 달라서
System.out.println(s.equals(s3)); // true --> 내용물이 같은지 봐서
👀 관찰 포인트 — class java.lang.String(두 번), class hk.edu20260806.day04.D3_ObjectTest, hk.edu20260806.day04.D3_ObjectTest@(16진수), Object, (10진수 해시값), false, true, true, false, true 순으로 나와요(해시값은 실행마다 달라질 수 있음). 우리 클래스의 toString은 Object 기본형이라 "이름@해시"지만, str.toString()은 String이 재정의해 둬서 글자 Object가 나옵니다. "a" 리터럴 둘은 String pool의 같은 객체를 가리켜 ==도 true, new String("a")는 새 객체라 ==는 false — 그래도 equals는 둘 다 true예요.
private 필드와 getter/setter — 캡슐화
7장 TV 코드의 나머지 절반입니다. 중요한 값(size)을 private로 숨기고, 메서드를 통해서만 읽고 쓰게 해서 "조건부 공개"가 가능하다는 것을 보여 주려고 만든 부분이에요.
JAVAD4_Constructor.java — getter / setter
// private은 클래스 내부에서만 접근 가능
private int size = 0; // 중요한 데이터 -->private 선언
public String color = "검정석"; // 색상
// …(생략: 생성자 — 7장)
// private으로 선언한 맴버필드는 어떻게 접근할까?->getter, setter메서드를 사용한다
public int getSize(int pw) {
// 조건에 따라 값을 반환한다. ex: 비밀번호를 알고 있다면 반환
if (pw == 1234) {
return size;
} else {
System.out.println("잘못된 비밀번호");
return -1;
}
}
public void setSize(int size) {
this.size = size;
}
👀 관찰 포인트 — 다른 클래스(D1_ConstructioMain)에서 tv4.size = 24;라고 쓰면 size has private access in D4_Constructor 컴파일 에러가 나요. 그래서 tv4.setSize(24)를 썼죠. 반면 color는 public이라 tv4.color = "파란색"이 바로 됩니다. getter에 비밀번호 검사를 넣은 것처럼, 통로가 메서드이기 때문에 규칙을 끼워 넣을 수 있다는 게 캡슐화의 핵심이에요.
부모 타입 Object에 담았다가 다시 꺼내기
5일차 코드의 이 부분은 "모든 클래스의 부모가 Object"이기 때문에 어떤 객체든 Object 변수에 담을 수 있고, 담으면 Object의 기능만 보인다는 것을 보여 주려고 만든 실습입니다.
JAVAD3_Singleton.java (5일차) — 참조타입 형 변환
// 참조타입끼리 형변환
D3_Singleton sm = new D3_Singleton();
sm.test();
Object obj = sm; // Object 부모 객체(더 큰 개념) -> 자동 형변환
// obj.test(); //형변환되면 설계도가 바뀌기 때문에 test()를 못찾음
D3_Singleton afterSm = (D3_Singleton) obj; // b=(byte)200 같은거임
afterSm.test();// 이제 설계도에 test()가 보이므로 호출 가능
// 주의사항: 객체간에 관계가 없는 것끼리는 형변환X
👀 관찰 포인트 — singleton메서드가 두 번 출력됩니다. obj와 afterSm은 같은 객체를 가리키지만, 변수의 타입(설계도)이 Object일 때는 test()가 안 보이고 D3_Singleton으로 바꾸면 보여요. 2장의 기본타입 형 변환(작은 → 큰 자동, 큰 → 작은 강제)과 모양이 같다는 원본 주석의 비유도 확인하세요. 단, 기본타입과 달리 객체 자체는 전혀 바뀌지 않고 "보는 시점"만 바뀝니다.
기본타입 변수는 값을, 참조타입 변수는 객체의 주소를 담는다는 것을 그림으로 설명할 수 있다.
자바는 항상 값 전달이고, 참조타입은 "주소값이 복사"될 뿐이라는 것을 코드 결과로 설명할 수 있다.
final 변수 · 참조타입 final · static final 상수의 차이를 안다.
Wrapper 클래스와 박싱/언박싱을 이해하고, 싱글턴 패턴의 세 요소를 말할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
6장에서 "인수의 값이 매개변수에 복사된다"고 했어요. 그런데 객체를 넘기면 메서드 안에서 고친 내용이 밖에서도 보입니다. 모순 같죠? 7~8장에서 본 "변수에는 객체의 주소가 담긴다"를 적용하면 깔끔하게 풀려요. 5일차 코드는 이 차이(D1_ImmutableTest)와, 바꿀 수 없게 막는 final, 기본타입을 객체로 감싸는 Wrapper, 객체를 하나만 만드는 싱글턴을 차례로 실험합니다.
개념
① 기본타입 vs 참조타입 — 상자 안의 값 vs 상자 안의 쪽지
기본타입 변수(int a = 5)의 상자에는 값 5가 직접 들어 있어요. 참조타입 변수(D1_ImmutableTest imTest = new …())의 상자에는 객체가 아니라 "객체는 Heap의 몇 번지에 있음"이라는 쪽지(참조)가 들어 있습니다. 참조타입은 클래스 · 배열 · 인터페이스 등 기본타입 8개를 뺀 전부예요. 그래서 b = a로 참조를 복사하면 쪽지만 복사되어 두 변수가 같은 객체를 가리킵니다.
📎 교안 p.65 「기본타입 & 참조타입 (4/4)」
② 메서드에 넘기기 — 자바는 언제나 "복사해서" 넘긴다
메서드를 부르면 인수의 상자 안 내용이 매개변수에 복사돼요. 기본타입이면 값이 복사되니, 메서드가 매개변수를 바꿔도 원본은 그대로. 참조타입이면 쪽지(주소)가 복사되니, 복사된 쪽지를 따라가 객체 안을 고치면(imTest.bb = 10) 원본 변수도 같은 객체를 보고 있어서 바뀐 게 보입니다. 하지만 매개변수에 새 객체를 대입하면(st = "안녕하세요") 복사본 쪽지만 바뀔 뿐 원본 변수는 그대로예요. 둘 다 "값(=쪽지) 전달"입니다.
final 변수는 값을 딱 한 번만 넣을 수 있어요. 선언하면서 넣거나, 필드라면 생성자에서 넣을 수도 있습니다(public final int num2; → 생성자에서 대입). 참조타입에 final을 붙이면 다른 객체로 바꾸는 것(쪽지 교체)만 금지되고, 가리키는 객체의 내용은 여전히 고칠 수 있어요. 모두가 공유하는 고정값은 public static final로 만들고 이름을 대문자로 짓는 게 상수의 관례입니다. 메서드에 붙이면 오버라이딩 금지, 클래스에 붙이면 상속 금지(대표 예: String).
📎 교안 p.67 「final / 상수」
④ Wrapper 클래스와 박싱 · 언박싱
기본타입마다 대응하는 클래스가 있어요: int ↔ Integer, char ↔ Character, 나머지는 첫 글자만 대문자(Double, Boolean …). 기본값을 객체 상자에 넣는 게 박싱, 꺼내는 게 언박싱인데, Java 5부터 자동으로 해 줍니다(Integer ii = a;, int b = ii;). 왜 필요하냐면 List<Integer> 같은 컬렉션과 Object 변수는 객체만 담을 수 있기 때문이에요.
📎 교안 p.68~70 「Wrapper Class · Boxing & UnBoxing」
⑤ 싱글턴 — 객체를 딱 하나만
설정 관리자처럼 프로그램 전체에 하나만 있어야 하는 객체를 만드는 설계 방법이에요. 세 가지가 필요합니다: ① private 생성자 — 밖에서 new 금지. ② private static 자기 타입 필드 — 만들어 둔 하나를 보관. ③ public static getInstance() — 객체 없이 부를 수 있어야 하니 static이고, 처음 부를 때만 new 하고 이후엔 보관해 둔 것을 돌려줘요. (이 단순한 방식은 여러 스레드가 동시에 처음 부르면 두 개가 생길 수 있어서, 스레드를 배운 뒤에는 보완한 방식을 씁니다.)
📎 교안 p.71~72 「Singleton Design」
⚠️ 교안 표현 바로잡기
교안 p.65·66(그리고 p.58)은 참조타입을 「pass by reference」, String만 「pass by value」라고 나누지만, 자바는 항상 pass by value입니다. 참조타입은 주소값이 복사되어 넘어갈 뿐이에요. 교안의 String 예(change(String st) { st = "안녕하세요"; } 후 원본 그대로)는 String이라 특별해서가 아니라 매개변수에 새 객체를 대입했기 때문이고, 우리가 만든 클래스로 똑같이 해도 원본은 안 바뀝니다. 또 p.16의 "기본타입은 Stack"도 아래 예제의 bb처럼 필드면 Heap의 객체 안에 있어요. p.70의 new Integer(10)은 Java 9부터 사용 중단(deprecated), Java 16부터 제거 예정으로 표시돼 있으니 Integer.valueOf(10)이나 자동 박싱(Integer box = 10;)을 쓰세요.
예제 — 수업 코드로 확인하기
int를 넘길 때 vs 객체를 넘길 때
이 코드는 똑같이 "메서드 안에서 10으로 바꾸기"를 했는데 기본타입은 원본이 그대로, 객체의 필드는 원본이 바뀌는 차이를 출력으로 확인하려고 만든 실습입니다.
JAVAD1_ImmutableTest.java — 값 복사 vs 주소 복사
public class D1_ImmutableTest {
public static void main(String[] args) {
int a = 5;
change01(a);
System.out.println("원본 a변수의 값은?" + a);
D1_ImmutableTest imTest = new D1_ImmutableTest();
change02(imTest);
System.out.println("원본 bb변수의 값은?" + imTest.bb);
}
public static void change01(int a) {
a = 10; // 받은 쪽에서 10으로 값을 변경
}
public int bb = 5; // 멤버필드
public static void change02(D1_ImmutableTest imTest) {
int aa = imTest.bb; // 실제 값을 꺼내서 저장한뒤 사용
aa = 15;
imTest.bb = 10; // 직접 주소로 접근해서 값을 변경하는 경우 ->원본이 저장됨
}
}
👀 관찰 포인트 — 출력은 원본 a변수의 값은?5, 원본 bb변수의 값은?10. change01의 a는 main의 a와 이름만 같은 다른 상자(복사본)라 원본은 5 그대로예요. change02의 imTest도 복사본이지만 들어 있는 주소가 같아서 같은 객체의 bb를 고쳤고, 그래서 main에서도 10이 보입니다. aa = 15는 bb 값을 꺼내서 복사한 지역변수를 바꾼 것이라 아무 영향이 없어요.
final — 무엇이 금지되고 무엇이 허용되나
이 코드는 final 지역변수, 생성자에서 정하는 final 필드, 그리고 "final 배열의 칸은 바꿀 수 있지만 배열 자체는 못 바꾼다"를 한 번에 실험하려고 만든 실습입니다.
JAVAD2_FinalTest.java — final의 범위 (발췌)
// 참조타입 상수 선언: 값변경 금지(X), 주소변경 금지(O)
public static final int[] arrayNum = { 1, 2, 3, 4 };
// 멤버필드에 static을 사용해서 상수를 정의하자
public static final int num = 100;
// 생성자를 통해서 값을 초기화하는 코드를 작성한다면 초기값 정의 안해도됨
public final int num2;
public D2_FinalTest(int num2) {
this.num2 = num2;
}
public static void main(String[] args) {
final int b = 5; // 값이 변경되지 않음 -> 상수(상수는 대문자로 선언)
// b = 15; // 에러발생 -> final로 선언된 변수는 값을 변경할 수 없다
// …(생략)
D2_FinalTest ft1 = new D2_FinalTest(100);
D2_FinalTest ft2 = new D2_FinalTest(200);
arrayNum[0] = 10;
System.out.println(Arrays.toString(arrayNum));
int[] test = { 1, 2, 3, 4, 5 };
// arrayNum = test; // 에러발생 참조타입의 주소를 변경하려고 했다.
}
👀 관찰 포인트 — 출력은 [10, 2, 3, 4]. final 배열이어도 칸의 값은 바뀌었고, 주석 처리된 arrayNum = test;(다른 배열로 쪽지 교체)는 에러예요. num2는 final이지만 생성자에서 정하니 ft1은 100, ft2는 200으로 객체마다 다른 고정값을 가질 수 있습니다. 덧붙여 num, arrayNum은 관례대로라면 NUM, ARRAY_NUM처럼 대문자로 짓는 게 좋아요.
박싱 · 언박싱과 싱글턴 사용하기
이 두 파일은 기본타입이 Object·컬렉션에 들어갈 때 Wrapper로 자동 포장되는 과정과, new 대신 getInstance()로만 객체를 얻는 싱글턴을 보여 주려고 만든 실습입니다. 교안 p.71의 Singleton 예제를 수업에서 그대로 옮겨 짠 것이에요.
JAVAD3_Singleton.java — Wrapper와 싱글턴 사용 (발췌)
// 기본타입과 참조타입 변환
int a = 10;
Object obj2 = a; // 참조타입 <--- 기본타입
Integer ii = a;// 중간 진행 과정
// Integer iii = new Integer(a);
Object obj3 = ii;
System.out.println(obj3.toString());
int b = (int) obj3; // 언박싱해서 정수형으로 다시 사용한다.
List<Integer> list = new ArrayList<>();
list.add(10);
// list.add("가"); // List<Integer>에는 String을 넣을 수 없어 컴파일 에러 (원본 주석을 바로잡음)
// 싱글턴 패턴-------------------
// D3_SingletomTest st = new D3_SingletomTest(); //x
D3_SingletomTest st = D3_SingletomTest.getInstance();
D3_SingletomTest st2 = D3_SingletomTest.getInstance();
JAVAD3_SingletomTest.java — 싱글턴 클래스
public class D3_SingletomTest {
// static을 붙인 이유는 getInstance메서드가 static이라서
private static D3_SingletomTest st;
private D3_SingletomTest() {// 외부에서 접근 못함 --> new를 못함
}
public static D3_SingletomTest getInstance() {
if (st == null) { // 객체가 생성되지 않았을때만 생성하자
st = new D3_SingletomTest();
}
return st;
}
}
👀 관찰 포인트 — Object obj2 = a;는 a가 먼저 Integer로 자동 박싱된 뒤 Object에 담기는 거예요. obj3.toString()은 Integer가 재정의해 둔 덕분에 10이 출력되고, (int) obj3는 Integer로 보고 꺼내는 언박싱입니다. 싱글턴 쪽은 출력이 없으니 직접 System.out.println(st == st2);를 추가해 보세요 — true, 즉 두 번 불러도 같은 객체입니다. 주석 처리된 new D3_SingletomTest()를 풀면 생성자가 private이라 컴파일 에러가 나요.
핵심 정리
기본타입 변수 = 값, 참조타입 변수 = 객체의 주소. b = a는 주소 복사(같은 객체를 함께 가리킴).
자바는 항상 값 전달. 참조타입은 주소값이 복사되므로 객체 안을 고치면 원본에서도 보이지만, 매개변수에 새 객체를 대입해도 원본은 그대로.
final: 한 번만 대입. 참조 final은 "다른 객체로 교체" 금지일 뿐 내용 변경은 가능. 상수는 static final + 대문자.
Wrapper(Integer 등)로 기본값을 객체로 — 박싱/언박싱은 자동. new Integer() 대신 Integer.valueOf().
확인 문제change01로 main의 a를 정말 10으로 바꾸고 싶다면 어떻게 고쳐야 할까요?
메서드가 값을 돌려주게 하고 받아서 다시 대입합니다: static int change01(int a) { return 10; } → main에서 a = change01(a);. 기본타입은 복사본만 넘어가므로 결과를 return으로 돌려받아야 해요.
change02의 내용을 imTest = new D1_ImmutableTest(); imTest.bb = 10;으로 바꾸면 main이 출력하는 bb는?
5입니다. 복사본 쪽지 imTest가 새 객체를 가리키게 됐을 뿐이고, 10은 그 새 객체에 들어갔어요. main의 변수는 여전히 원래 객체(bb = 5)를 가리킵니다. 이것이 "자바는 값 전달"의 증거예요.
Integer x = 127, y = 127;일 때 x == y는 true인데, 128로 바꾸면 false가 나왔어요. 왜죠?
자동 박싱은 Integer.valueOf()를 쓰는데, -128~127 범위는 미리 만들어 둔 객체를 재사용(캐시)하고 그 밖은 새 객체를 만들기 때문입니다. ==는 같은 객체인지를 보니 결과가 달라져요. Wrapper끼리의 값 비교는 equals()로 하세요(8장의 == vs equals와 같은 이야기).
싱글턴의 getInstance()는 왜 반드시 static이어야 할까요?
생성자가 private이라 밖에서는 객체를 만들 수 없어요. 객체가 없는 상태에서 부를 수 있는 메서드는 static뿐이므로, 클래스명.getInstance()로 부를 수 있게 static이어야 합니다. 그 메서드가 쓰는 필드 st도 그래서 static이에요.
다음 장으로
값과 참조의 차이를 알았으니, 같은 5일차의 남은 실습 D4_StringCompare를 볼 준비가 됐어요. String은 참조타입인데도 값을 바꿀 수 없는(immutable) 특별한 클래스라서, ==/equals 비교와 String pool, 그리고 += 대신 StringBuilder를 쓰는 이유를 이어서 정리합니다.
String이 리터럴 문법이 있는 불변(immutable) 참조타입이라는 말이 무슨 뜻인지 설명할 수 있다.
문자열을 비교할 때 ==가 아니라 equals()를 써야 하는 이유를 메모리 그림으로 말할 수 있다.
charAt·indexOf·substring·replace·Integer.parseInt로 문자열을 찾고, 자르고, 숫자로 바꿀 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
앞에서 참조타입 변수는 객체가 아니라 주소를 들고 있다는 걸 배웠어요. 5일차 D1_ImmutableTest에서 "메서드에 넘긴 값을 바꾸면 원본도 바뀌나?"를 실험한 것도 그 연장입니다. String은 매일 쓰는 타입인데, 겉보기엔 int처럼 쓰여도 속은 참조타입이라 비교와 변경에서 초보자가 가장 많이 걸려 넘어집니다. 그래서 참조타입을 배운 바로 다음에 String을 따로 한 장으로 다룹니다.
개념
① 불변(immutable) — 고치는 게 아니라 새로 만든다
String은 도장을 찍어 놓은 종이와 같아요. 한 번 만들어지면 글자를 고칠 수 없고, 고친 것처럼 보이는 메서드(replace, substring, toUpperCase …)는 전부 새 String을 만들어 돌려줄 뿐입니다. 그래서 s.replace("자바", "python");만 쓰면 s는 그대로이고, s = s.replace(…);처럼 다시 대입해야 바뀐 결과를 씁니다.
+는 문자열을 만나는 순간부터 문자열 잇기가 됩니다. 왼쪽부터 계산하므로 1 + 2 + "hello"는 "3hello", "hello" + 1 + 2는 "hello12"예요. 반복문 안에서 +=로 계속 이으면 매번 새 객체가 생기므로, 그럴 땐 내용을 바꿀 수 있는(mutable) StringBuilder의 append()를 씁니다.
📎 교안 p.74 「String」, p.86 「String Buffer」
② 리터럴과 new — String pool
따옴표로 바로 쓴 리터럴"java"는 String pool이라는 공용 보관함에 한 번만 만들어지고, 같은 리터럴을 또 쓰면 그 객체를 같이 가리킵니다. 반면 new String("java")는 쓸 때마다 Heap에 새 객체를 만듭니다. 글자는 같아도 주소가 다른 객체가 생기는 거예요. (Java 7부터 String pool도 Heap 안에 있습니다.)
📎 교안 p.75~77 「String 특징 - 메모리구조」
③ ==와 equals() — 주소 비교 vs 글자 비교
참조타입에서 ==는 "같은 객체(같은 주소)인가?"를 묻습니다. String의 equals()는 Object의 것을 오버라이딩해서 "글자가 하나하나 같은가?"를 비교해요. 리터럴끼리는 pool의 같은 객체라 ==도 우연히 true가 되지만, 언제 new로 만든 문자열이 섞일지 모르므로 문자열 내용 비교는 항상 equals()로 합니다. 6일차 계산기에서 cal.equals("+")로 연산자를 비교한 것도 이 때문입니다.
📎 교안 p.82~85 「String 비교」
④ 자주 쓰는 메서드 — 찾고, 자르고, 바꾸기
인덱스는 0부터 셉니다. charAt(i)는 i번째 글자(char), indexOf("찾을것")은 처음 나온 위치(없으면 -1), indexOf("찾을것", 시작)은 그 위치부터 다시 찾기, substring(시작, 끝)은 끝 인덱스 바로 앞까지 잘라 냅니다. 자른 "10"을 계산에 쓰려면 Integer.parseInt("10")으로 정수로 바꾸고, 반대로 값을 문자열로 만들 땐 String.valueOf(값) 또는 값 + ""를 씁니다.
📎 교안 p.78~81 「String 주요 메서드」, p.87 「String Cutter」
⚠️ 교안 표현 바로잡기
교안 p.74는 String을 「참조타입 중 유일하게 기본타입의 특징을 가지고 있다」고 하지만, 정확히는 리터럴 문법이 있는 불변 참조타입입니다. 변수에는 여전히 주소가 들어 있고, 기본타입처럼 보이는 건 따옴표로 만들 수 있고 값이 바뀌지 않기 때문이에요.
교안 p.75는 「객체가 생성이 되면 heap의 영역 중에 String pool 영역에 생성」된다고 하지만, pool에 들어가는 건 리터럴(과 intern()한 문자열)뿐입니다. new String(…)은 pool 밖의 일반 Heap에 생깁니다.
교안 p.82~85는 equals()가 「hashcode를 비교」한다고 하지만, String의 equals()는 글자 내용을 비교합니다. hashCode()는 HashMap 같은 해시 자료구조가 쓰는 보조값이고 고유하지 않아요("Aa"와 "BB"는 해시값이 둘 다 2112인데 equals는 false).
교안 p.87의 substring(3, 5)는 인덱스 3과 4만 잘라 냅니다(5는 포함 안 됨). 또 split(",")에서 쉼표 사이가 비어 있으면 그 칸은 null이 아니라 빈 문자열 ""입니다.
예제 — 수업 코드로 확인하기
리터럴·new·불변을 한 파일에서 실험하기
이 코드는 "같은 글자인데 왜 == 결과가 다를까"와 "replace를 했는데 왜 원본이 그대로일까"를 보여 주려고 만든 실습입니다. 세 가지 조합(리터럴끼리, new끼리, 섞어서)을 나란히 찍어 비교해요.
JAVAD4_StringCompare.java (day05) — == vs equals, immutable
// 리터럴과 리터럴 비교
String s1 = "java";
String s2 = "java";
System.out.println((s1 == s2) + ":" + (s1.equals(s2)));
// 객체와 객체 비교
String obj1 = new String("java");
String obj2 = new String("java");
System.out.println((obj1 == obj2) + ":" + (obj1.equals(obj2)));
// 객체와 리터럴
System.out.println((s1 == obj1) + ":" + (s1.equals(obj1)));
// …(생략: += 반복과 StringBuilder 비교)
// String 특징: immutable -> 값을 변경할 수 없다.
String s = "java";// 원본
String sss = s;// 복사본
sss = "자바";// 값을 변경
System.out.println(s); // 원본은 그대로
String s4 = s.replace("j", "o");// 원본이 바뀌지 않는다
System.out.println(s4); // 바뀐 내용을 사용하려면 재할당받아야 한다
System.out.println(s);
👀 관찰 포인트 — 출력은 true:true → false:true → false:true. 리터럴끼리만 같은 pool 객체라 ==가 true이고, equals()는 세 경우 모두 true예요. 아래쪽은 java → oava → java: sss = "자바"는 sss가 다른 객체를 가리키게 바꾼 것일 뿐 s가 가리키는 "java"는 그대로이고, replace도 새 문자열을 돌려줄 뿐 원본은 안 바뀝니다.
문자열 검색과 "5+10" 파싱
6일차의 두 코드는 indexOf로 위치를 찾고 → substring으로 잘라 → parseInt로 숫자로 바꾸는 흐름을 손에 익히려고 만든 실습입니다. 첫째는 기사 본문에서 검색어를 모두 찾고, 둘째는 키보드로 받은 "5+10"을 숫자 두 개로 쪼갭니다.
JAVAD2_StringMethodTest.java (day06) — search(): 검색어 모두 찾기
int count = 0;
int searchIndex = 0; // 검색 시작 위치
while (true) {
int index = s.indexOf(str, searchIndex);
if (index == -1) {
break; // 더 이상 검색어가 없으면 반복 종료
}
System.out.println("검색어 인덱스: " + index);
System.out.println("추출된 검색어: " + s.substring(index, index + str.length()));
count++;
searchIndex = index + str.length(); // 다음 검색 시작 인덱스 업데이트
}
System.out.println("검색된 개수: " + count + "개");
System.out.println(s.replace(str, "###"));
public void paramInt(String s, String cal) {
// s-> "5+10" -> "5"만 추출해서 정수형으로 변환하여 num1에 저장
if (s.indexOf(cal) != -1) {
num1 = Integer.parseInt(s.substring(0, s.indexOf(cal)));
// s-> "5+10" -> "10"만 추출해서 정수형으로 변환하여 num2에 저장
// s.length()가 아니라 연산자 길이(cal.length())를 더해야 함!
num2 = Integer.parseInt(s.substring(s.indexOf(cal) + cal.length()));
}
}
👀 관찰 포인트 — 검색 루프는 찾은 위치 바로 뒤(index + str.length())에서 다시 찾기 때문에 같은 곳을 두 번 세지 않고, -1이 나오면 멈춥니다. 마지막 줄의 replace 결과를 출력만 했으니 s 자체는 여전히 원문이에요(불변). 파싱 쪽은 "5+10"에서 indexOf("+")가 1이므로 substring(0, 1) = "5", substring(2) = "10"이 됩니다. 참고로 D3_CalculatorMain의 정규식 [+|\\-|*|/]에서 대괄호 안의 |는 "또는"이 아니라 글자 | 자체라서 [+\\-*/]로 쓰는 게 의도에 맞습니다.
핵심 정리
String은 불변: 바꾸는 메서드는 새 String을 돌려준다 → 결과를 쓰려면 s = s.replace(…)처럼 다시 대입.
리터럴은 String pool에서 공유, new String()은 매번 새 객체.
==는 같은 객체인지(주소), equals()는 글자가 같은지 — 문자열 비교는 항상 equals().
indexOf는 없으면 -1, substring(a, b)는 b 앞까지, 숫자 변환은 Integer.parseInt.
반복해서 이어 붙일 땐 StringBuilder.append().
확인 문제String a = new String("hi"); String b = "hi";일 때 a == b와 a.equals(b)는?
false와 true. a는 Heap의 새 객체, b는 pool의 리터럴이라 주소가 다르지만 글자는 같습니다.
s.replace("자바", "python"); System.out.println(s);가 원래 글자를 출력하는 이유는?
String은 불변이라 replace가 원본을 고치지 않고 새 문자열을 돌려주는데, 그 결과를 아무 변수에도 받지 않았기 때문입니다. s = s.replace(…);로 다시 대입해야 합니다.
배열을 선언하는 세 가지 방법과, new int[5]처럼 만들었을 때 채워지는 기본값(0, false, null)을 말할 수 있다.
참조 복사(b = a) · 얕은 복사(clone, arraycopy) · 깊은 복사를 구분해서 결과를 예측할 수 있다.
2차원 ↔ 1차원 변환 공식 [i * col + j], [i / col][i % col]을 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
10장의 String은 불변이라 "바꾸면 새 객체"였죠. 배열은 같은 참조타입인데 내용을 직접 바꿀 수 있습니다. 그래서 "주소를 나눠 가진 두 변수"라는 참조타입의 성질이 배열에서 가장 또렷하게 드러나요. 그리고 다음 장의 로또(번호 6개)와 마방진(2차원 판)이 전부 배열 위에서 돌아가기 때문에, 여기서 선언과 복사를 확실히 해 둬야 합니다.
개념
① 배열 — 같은 타입 칸들의 묶음, 선언 3가지
배열은 번호(인덱스 0부터) 붙은 사물함 한 줄입니다. 만들 때 칸 수가 정해지고 바꿀 수 없어요(배열.length로 확인). 선언 방법은 세 가지입니다.
int[] a = {1, 2, 3}; — 리터럴처럼 선언과 동시에 값 넣기(선언할 때만 가능)
int[] b = new int[] {1, 2, 3}; — new로 만들면서 값 넣기(나중에 대입할 때도 가능)
int[] c = new int[3]; — 칸만 만들기. 자동으로 int는 0, double은 0.0, boolean은 false, 참조타입은 null로 채워짐
📎 교안 p.89 「배열 (기본)」, p.92 「Enhanced for」
② 2차원 배열 — 배열을 담은 배열
int[][] aa = {{1,2,3},{4,5,6}};은 "길이 3짜리 배열 2개를 담은 배열"입니다. 그래서 aa.length는 행 수(2), aa[0].length는 열 수(3)예요. 2차원 칸 (i, j)를 한 줄로 펴면 i * 열수 + j번째, 반대로 한 줄의 k번째를 다시 접으면 [k / 열수][k % 열수]입니다. 이 공식은 4의 배수 마방진에서 1~16을 차례로 채울 때 그대로 다시 나옵니다.
📎 교안 p.90~91 「배열변환 (2차원 ↔ 1차원)」
③ 복사 3단계 — 참조 복사 / 얕은 복사 / 깊은 복사
집 열쇠로 비유해 볼게요. 참조 복사b = a는 열쇠만 하나 더 깎은 것 — 집(배열)은 하나라서 b로 바꾸면 a에서도 바뀝니다. 얕은 복사(a.clone(), System.arraycopy, Arrays.copyOf)는 새 집을 짓고 가구 목록을 그대로 옮긴 것 — 칸에 든 값이 숫자(기본타입)면 완전히 따로 놀지만, 칸에 든 게 다른 객체의 주소면 그 객체는 두 집이 같이 씁니다. 깊은 복사는 그 객체들까지 하나하나 새로 만들어 넣는 것이에요.
그래서 int[]는 clone만으로 독립된 복사본이 되지만, int[][]는 clone하면 바깥 배열만 새로 생기고 안쪽 행 배열은 공유됩니다(int[][] c = m.clone(); c[0][0] = 9; → m[0][0]도 9). 행마다 m[i].clone()을 따로 해 줘야 깊은 복사예요.
📎 교안 p.93~94 「Shallow Copy & Deep Copy」
⚠️ 교안 · 수업 코드 표현 바로잡기
교안 p.93은 Shallow Copy를 「주소값 전달, 복사본을 변경하면 원본도 같이 변경」이라고 설명하고, 수업 코드도 b = a; // 주소복사(얕은 복사)라고 적었지만, 이건 참조 복사입니다. 새 배열이 하나도 생기지 않았거든요. 얕은 복사는 새 배열을 만들되 원소를 그대로 옮기는 것입니다.
교안 p.94의 「깊은 복사를 하려면? System.arraycopy」와 수업 코드 주석 「깊은복사 기능: arraycopy() / clone()」도 정확히는 얕은 복사입니다. int[]처럼 원소가 기본타입이면 결과가 깊은 복사와 같아 보일 뿐, 2차원 배열이나 객체 배열에서는 안쪽을 공유합니다. (교안 p.94 표의 "기본형은 값 복사, 참조형은 참조 복사"가 바로 이 뜻이에요.)
예제 — 수업 코드로 확인하기
선언 3가지와 복사 실험
이 코드는 배열을 만드는 세 가지 방법과, 복사했을 때 원본이 바뀌는지 안 바뀌는지를 직접 확인하려고 만든 실습입니다.
JAVAD2_ArrayTest.java (day07) — 선언 · 참조 복사 · arraycopy
// 1. 리터럴방식: 기본타입처럼 선언
int[] a = { 1, 2, 3, 4, 5, 6 }; // 바로 선언과 동시에 초기화를 해야 함
int[] b = null;
b = a;// 주소복사(얕은 복사) ← 정확히는 '참조 복사'
b[0] = 10;
System.out.println(Arrays.toString(a));
// 2. new를 사용하는 정의
int[] b2;
b2 = new int[] { 1, 2, 3, 4, 5 };
// 선언과 정의(자릿수) -> 자동초기화 지원(int면 0으로 초기화됨)
int[] b3 = new int[5];
for (int i = 0; i < b3.length; i++) {
b3[i] = i + 1;
}
// …(생략: sort, String replace 비교)
// 깊은복사 ← 원소를 하나씩 옮기기
int[] e = new int[] { 1, 2, 3, 4, 5, 6 };
for (int i = 0; i < b3.length; i++) {
e[i] = b3[i];
}
// 깊은복사 기능 : System.arraycopy() 단, 값의 타입이 기본타입일 경우
int[] f = new int[5];
// (원본대상배열,복사할 시작위치, 복사받을 배열, 시작위치, 복사할 길이)
System.arraycopy(e, 0, f, 0, f.length);
👀 관찰 포인트 — 출력은 [10, 2, 3, 4, 5, 6]. b로 바꿨는데 a가 바뀌었죠? b = a는 새 배열을 만들지 않았기 때문입니다. 반면 f는 new int[5]로 새 배열을 먼저 만들고 값을 옮겼으니 f를 고쳐도 e는 그대로예요. 주석의 "단, 값의 타입이 기본타입일 경우"라는 조건이 바로 "얕은 복사지만 원소가 숫자라 독립적"이라는 뜻입니다.
2차원 ↔ 1차원 변환
이 코드는 교안 p.91의 두 공식을 그대로 코드로 옮겨 보려고 만든 실습입니다. 2×3 배열을 길이 6짜리로 폈다가 다시 접어요.
JAVAD2_ArrayTest.java (day07) — 배열 변환
int[][] bb = new int[][] { { 1, 2, 3 }, { 4, 5, 6 } }; // 2행 3열
// …(생략)
// 2차원 배열 --> 1차원 배열 변환
int[] dd = new int[bb.length * bb[0].length];
for (int i = 0; i < bb.length; i++) {
for (int j = 0; j < bb[i].length; j++) {
dd[i * bb[0].length + j] = bb[i][j];
}
}
System.out.println(Arrays.toString(dd));
// 1차원배열 --> 2차원배열
// 공식: [i/col][i%col]
int[][] ee = new int[2][3];
int col = ee[0].length;
for (int i = 0; i < dd.length; i++) {
ee[i / col][i % col] = dd[i];
}
for (int i = 0; i < ee.length; i++) {
System.out.println(Arrays.toString(ee[i]));
}
👀 관찰 포인트 — [1, 2, 3, 4, 5, 6] 다음에 [1, 2, 3], [4, 5, 6]이 찍힙니다. 공식에 들어가는 건 행 수가 아니라 열 수(bb[0].length, col)라는 점에 주목하세요. 한 행에 몇 칸이 있는지 알아야 다음 행으로 넘어가는 지점을 알 수 있으니까요.
개미수열 — 문자열 끝 처리 꾀
7일차는 6일차 문자열 메서드를 복습하려고 개미수열(1 → 11 → 12 → 1121 …)로 시작했어요. 이 코드는 charAt으로 이웃한 두 글자를 비교하며 개수를 세는 법을 익히려고 만든 실습입니다.
JAVAD1_Ant.java (day07) — 개미수열
int count = 1;
for (int i = 0; i < total; i++) {
String next = "";
ant = ant + " "; // 끝에 공백 하나: 마지막 묶음도 '다른 글자'를 만나게
for (int j = 0; j < ant.length() - 1; j++) {
if (ant.charAt(j) == ant.charAt(j + 1)) {
count++;
} else {
next = next + ant.charAt(j) + count; // "숫자 + 개수"
count = 1;
}
}
ant = next;
System.out.println(ant);
}
👀 관찰 포인트 — "1"과 5를 입력하면 11, 12, 1121, 122111, 112213이 나옵니다. 끝에 공백을 붙이지 않으면 마지막 묶음은 비교할 다음 글자가 없어 next에 안 들어가요. char끼리라서 == 비교가 맞고(기본타입), next + ant.charAt(j) + count는 문자열을 먼저 만났으니 전부 문자열 잇기가 됩니다.
핵심 정리
배열은 크기가 고정된 참조타입. new로 만들면 0 / 0.0 / false / null로 자동 초기화.
2차원 배열의 length는 행 수, [0].length는 열 수. 펴기 i*col+j, 접기 [k/col][k%col].
b = a는 참조 복사(배열 1개), clone·arraycopy는 얕은 복사(새 배열, 원소는 그대로), 원소가 가리키는 객체까지 새로 만들면 깊은 복사.
기본타입 1차원 배열은 얕은 복사만으로 독립. 2차원·객체 배열은 안쪽까지 따로 복사해야 독립.
확인 문제int[] a = {1,2,3}; int[] b = a; b[0] = 9; 후 a[0]은?
9. a와 b가 같은 배열 하나를 가리키는 참조 복사입니다.
int[] c = a.clone(); c[1] = 7; 후 a[1]은 바뀌나요?
안 바뀝니다. clone은 새 배열을 만들고, 원소가 int 값이라 따로 복사됐어요.
int[][] m = {{1,2},{3,4}}; int[][] n = m.clone(); n[0][0] = 9; 후 m[0][0]은?
9. clone은 바깥 배열만 새로 만들고, 안쪽 행 배열 {1,2}는 m과 n이 같이 가리킵니다(얕은 복사).
2행 3열 배열을 1차원으로 폈을 때 인덱스 4는 2차원의 어디인가요?
[4/3][4%3] = [1][1] (값으로는 5).
다음 장으로
배열에는 숫자뿐 아니라 객체도 담을 수 있어요. 12장에서는 로또 한 장(번호 6개짜리 배열을 가진 객체)을 여러 장 담는 D1_Lotto[]를 만들고, 클래스끼리 일을 나누는 캡슐화와 물려받는 상속으로 넘어갑니다.
필드를 private으로 숨기고 getter로만 꺼내게 하는 캡슐화의 이유를 설명할 수 있다.
extends로 상속받은 자식을 만들 때 부모 생성자가 먼저 실행되는 순서(super())를 출력으로 예측할 수 있다.
오버라이딩(재정의)과 오버로딩을 구분하고, @Override를 붙이는 이유를 안다.
protected가 private·public과 어떻게 다른지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
지금까지는 클래스 하나에 모든 걸 넣었어요. 프로그램이 커지면 "누가 무슨 일을 맡나"를 나눠야 합니다. 교안 p.96의 OOP 3대 개념(은닉화·상속성·다형성) 중 앞의 두 개가 이번 장이에요. 6일차 계산기로 숨기기를, 8일차 로또로 클래스끼리 일 나누기를, 8~9일차 Parent/Child·Customer로 물려받고 고쳐 쓰기를 연습했습니다. 이게 13장 다형성의 전제 조건이에요.
개념
① 캡슐화(은닉화) — 금고는 숨기고 창구만 연다
은행은 금고(데이터)를 손님이 직접 열게 하지 않고 창구(메서드)로만 돈을 주고받죠. 클래스도 중요한 필드는 private으로 막고, 필요한 만큼만 public 메서드(getter/setter)로 열어 둡니다. 6일차 계산기의 private int result; + getResult()가 그 예예요. 바깥에서는 결과를 읽을 수만 있고 함부로 덮어쓸 수 없습니다.
캡슐화는 "일을 나누는 것"과도 이어집니다. 8일차 로또는 D1_Lotto(로또 한 장: 번호 6개 만들기·중복 검사), D1_LottoStore(여러 장 보관), D1_LottoUtil(등수 판정 같은 공통 기능, static)으로 책임을 나눴어요. 객체 배열은 new D1_Lotto[count]만 하면 칸이 전부 null이라(11장의 기본값), lottos[i] = new D1_Lotto();로 한 칸씩 채워야 합니다.
class D2_Child extends D2_Parent라고 쓰면 자식은 부모의 필드·메서드를 자기 것처럼 씁니다(자바 클래스는 부모 하나만). 자식 객체 안에는 부모 부분이 들어 있어서, 자식을 만들려면 부모 부분부터 초기화해야 해요(교안의 "자생부생"). 그래서 자식 생성자의 첫 줄은 항상 super(…)(부모 생성자) 또는 this(…)(내 다른 생성자)이고, 둘 다 안 쓰면 컴파일러가 super();를 몰래 넣어 줍니다.
부모의 private 필드는 자식이 직접 못 씁니다. 그래서 9일차 Customer는 필드를 protected로 열었어요. protected는 같은 패키지 + 다른 패키지의 자식 클래스까지 접근을 허용합니다.
부모의 메서드와 이름·매개변수·리턴타입이 같게 자식에서 다시 정의하면, 그 객체에서는 자식 버전이 실행됩니다. 위에 @Override를 붙이면 컴파일러가 "부모에 정말 이런 메서드가 있나" 검사해 줘서 오타를 잡아 줘요. 오버로딩(같은 이름, 다른 매개변수 — 한 클래스 안의 여러 버전)과 이름이 비슷하지만 전혀 다른 기능입니다. static 메서드, private 메서드, 생성자는 오버라이딩되지 않아요. 모든 클래스의 부모인 Object의 toString()을 오버라이딩하면 println(객체)의 출력이 바뀝니다.
📎 교안 p.103~104 「Override」
⚠️ 교안 · 수업 코드 표현 바로잡기
교안 p.103은 「실행 시 자동으로 오버로드 된 자식의 메서드가 호출」이라고 쓰지만, 여기서는 오버라이드(재정의)된 메서드입니다. 오버로드는 매개변수가 다른 같은 이름 메서드를 여러 개 두는 것이라 다른 개념이에요.
9일차 D1_Customer의 주석 「멤버필드 private 선언했기 때문에 메서드를 통해 접근」은 실제 코드와 다릅니다. 필드는 protected로 선언돼 있어요(그래서 같은 패키지의 D1_CustomerMain에서 customerJung.bonusPoint = 1000;이 됩니다).
D1_VIPCustomer 생성자는 super(…)를 적지 않았기 때문에, 먼저 부모의 기본 생성자 D1_Customer()가 실행돼 등급을 "SILVER"로 넣은 뒤 자식이 "VIP"로 덮어씁니다. 결과는 맞지만 super(customerID, customerName);로 부모에게 맡기는 쪽이 의도가 분명합니다.
예제 — 수업 코드로 확인하기
계산기 — 결과는 숨기고, 연산은 클래스별로 나누기
이 코드는 "결과처럼 중요한 값은 private으로 숨기고 getter로만 꺼낸다"와 "연산마다 클래스를 나누고, 한 클래스가 골라서 맡긴다"를 보여 주려고 만든 실습입니다. 덧셈 A·뺄셈 B·나눗셈 C·곱셈 D가 모두 같은 모양이에요.
JAVAD1_CalculatorA.java (day06) — 덧셈 담당
public class D1_CalculatorA {
public int num1;
public int num2;
// 계산 결과는 중요한 값이기 때문에 외부에서 쉽게 접근하지 못하게 하자
private int result;
public D1_CalculatorA() {
this(10, 5);
}
public D1_CalculatorA(int num1, int num2) {
this.num1 = num1;
this.num2 = num2;
}
public void a() {
this.result = this.num1 + this.num2;
}
// getter 메서드:private 필드에 접근하여 값을 가져오기 위해 선언
public int getResult() {
return result;
}
}
JAVAD1_CalculatorCompare.java (day06) — 연산자 보고 맡기기
private int result; // 연산결과
public void calculator(int num1, int num2, String cal) {
if (cal.equals("+")) {
D1_CalculatorA calA = new D1_CalculatorA(num1, num2);
calA.a(); // 덧셈연산 실행
this.result = calA.getResult(); // 은닉화: getter메서드 통해 결과 가져오기
} else if (cal.equals("-")) {
// …(생략: B·D·C도 같은 모양)
} else {
System.out.println("잘못된 연산자입니다.");
}
}
👀 관찰 포인트 — D1_CalculatorMain은 50과 20으로 네 연산을 돌려 70, 30, 1000, 2를 출력합니다. Compare 클래스는 calA.result를 직접 읽지 못하고 반드시 getResult()를 거쳐요. 연산자 비교에 10장에서 배운 equals()를 쓴 것도 확인하세요. (더 엄격하게 하려면 num1, num2도 private으로 바꿀 수 있습니다.)
Parent / Child — 생성자 순서와 오버라이딩
이 코드는 자식을 만들 때 부모 생성자가 먼저 불린다는 것과 오버라이딩하면 부모 메서드 대신 자식 메서드가 실행된다는 것을 출력으로 확인하려고 만든 실습입니다.
JAVAD2_Parent.java (day08) — 부모
public class D2_Parent {
public int a;
public D2_Parent() {
System.out.println("부모생성자(default)");
}
public D2_Parent(int a) {
System.out.println("부모생성자(오버로딩)");
}
public void parentMethod() {
System.out.println("부모의 메서드:" + getClass());
}
// …(생략: toString 오버라이딩)
}
JAVAD2_Child.java (day08) — 자식
public class D2_Child extends D2_Parent {
public String s = "나는 자식의 맴버필드 값이야";
public D2_Child() {
this(5);
System.out.println("자식생성자(default)");
}
public D2_Child(int a) {
super(a);// 부모가 먼저 생성된후 자식이 생성된다.
System.out.println("자식생성자(오버로딩)");
}
public void childMethod() {
parentMethod();// 부모메서드를 자식의메서드처럼 사용가능
System.out.println("자식클래스에서 정의된 메서드:" + getClass());
}
// 부모의 메서드를 자식이 재정의하는 기능
@Override
public void parentMethod() {
System.out.println("부모메서드를 자식이 재정의한 메서드");
}
@Override
public String toString() {
return s;
}
}
👀 관찰 포인트 — new D2_Child() 한 줄의 출력은 부모생성자(오버로딩) → 자식생성자(오버로딩) → 자식생성자(default). this(5)로 넘어가고, 거기서 super(a)로 부모부터 끝낸 뒤 거꾸로 돌아오기 때문이에요. 또 childMethod() 안의 parentMethod()는 주석과 달리 부모 버전이 아니라 재정의된 자식 버전("부모메서드를 자식이 재정의한 메서드")을 실행합니다. System.out.println(child2)가 주소 대신 나는 자식의 맴버필드 값이야를 찍는 건 toString()을 오버라이딩했기 때문이에요.
Customer / VIPCustomer — protected와 실전 오버라이딩
9일차는 장난감 같은 Parent/Child를 고객 등급이라는 실제 업무로 옮겼습니다. 이 코드는 공통 부분은 부모에 두고, 다른 부분(할인)만 자식이 재정의하는 모습을 보여 주려고 만든 실습입니다.
JAVAD1_Customer.java (day09) — 일반 고객(부모)
// private은 상속을 해줄 수 없어서 자식에서 사용하기 불편함
// -> protected로 선언하면 상속이 가능, 같은 패키지 안에서는 사용가능
protected int customerID; // 고객ID
protected String customerName;// 고객이름
protected String customerGrade;// 고객등급
protected int bonusPoint; // 보너스포인트
protected double bonusRatio; // 보너스 적립률
// …(생략: 생성자 2개 — 등급 "SILVER", 적립률 0.01로 시작)
// 보너스 적립률 계산해서 보너스 포인트 추가
public int calcPrice(int price) {
bonusPoint += price * bonusRatio; // 보너스점수 누적
return price; // 일단 그대로 리턴
}
JAVAD1_VIPCustomer.java (day09) — VIP 고객(자식)
public class D1_VIPCustomer extends D1_Customer {
private int agentID; // 담당 상담원 ID
private double saleRatio; // 할인율
// …(생략: 기본 생성자)
public D1_VIPCustomer(int customerID, String customerName, int agentID) {
super.customerID = customerID;
super.customerName = customerName;
super.customerGrade = "VIP";
super.bonusRatio = 0.05;
this.saleRatio = 0.1;
this.agentID = agentID;
}
// -> 자식에서는 할인률도 계산하는 기능이 추가
@Override
public int calcPrice(int price) {
super.bonusPoint += price * super.bonusRatio;
return (int) (price - (price * saleRatio)); // int로 강제 형변환
}
// …(생략: toString 오버라이딩)
}
👀 관찰 포인트 — 정우성(일반, 1000점에서 15000원 구매)은 1150점, 김연아(VIP, 10000점에서 100000원 구매)는 15000점이 되고 VIP의 결제액은 90000원입니다. 자식이 super.bonusPoint처럼 부모 필드를 바로 쓸 수 있는 건 protected 덕분이에요. calcPrice라는 같은 이름으로 불러도 객체가 VIP면 할인까지 계산됩니다 — 이게 다음 장 다형성의 씨앗입니다.
자식 생성자 첫 줄은 super(…) 또는 this(…). 안 쓰면 super()가 자동으로 들어가 부모가 먼저 초기화된다.
오버라이딩 = 부모와 같은 모양으로 다시 정의, @Override로 확인. 객체가 자식이면 자식 버전이 실행된다.
확인 문제계산기에서 result를 private으로 만든 이유는?
연산 결과는 a()를 실행해서만 정해져야 하는 중요한 값이라, 바깥에서 calA.result = 999;처럼 마음대로 바꾸지 못하게 하고 getResult()로 읽기만 허용한 것입니다.
부모에 기본 생성자 없이 D2_Parent(int a)만 있다면, 자식 생성자에서 super(…)를 빼먹으면 어떻게 되나요?
컴파일러가 넣어 주는 super();에 맞는 부모 생성자가 없어서 컴파일 에러가 납니다. 부모를 초기화하지 못하면 자식도 만들 수 없어요.
child.childMethod()를 실행했을 때 "부모의 메서드:"가 출력되지 않는 이유는?
childMethod 안에서 부른 parentMethod()는 실제 객체(D2_Child)의 것이 실행되는데, 자식이 오버라이딩했기 때문에 재정의된 문장이 나옵니다. 부모 버전을 꼭 부르고 싶다면 super.parentMethod()라고 써야 해요.
다음 장으로
8일차 코드에 D2_Parent child2 = new D2_Child();라는 줄이 있었죠. 부모 타입 변수에 자식 객체를 넣고, 같은 메서드를 불렀는데 자식 버전이 실행됐어요. 13장에서는 이게 왜 강력한지(다형성), 그리고 "반드시 재정의하라"고 강요하는 추상 클래스·인터페이스를 배웁니다.
부모 타입 변수로 여러 자식 객체를 다루고, 호출되는 메서드는 실제 객체가 정한다(VMI)는 것을 설명할 수 있다.
자식에만 있는 메서드를 쓰려면 instanceof로 확인하고 다운캐스팅해야 하는 이유를 안다.
추상 클래스(공통 구현 + 구현 강요)와 인터페이스(기능 약속)의 차이를 말할 수 있다.
인터페이스의 상수, default·static·private 메서드, 익명 클래스를 읽을 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
12장에서 상속과 오버라이딩을 배웠어요. 교안은 이 둘을 다형성의 전제 조건이라고 합니다(p.102). 이제 그 열매를 따 먹을 차례예요. 동물이 3종이든 30종이든 메서드 하나로 처리하고, "이 기능은 자식마다 다르니 꼭 직접 만들어라"를 문법으로 강제하는 방법을 배웁니다. 14장의 마방진 팩토리는 이 장의 내용을 그대로 조립한 것이에요.
개념
① 다형성 — 리모컨은 하나, 반응은 기계마다
만능 리모컨(부모 타입 변수)으로는 리모컨에 있는 버튼(부모에 선언된 메서드)만 누를 수 있어요. 하지만 같은 "전원" 버튼을 눌러도 TV냐 에어컨이냐(실제 객체)에 따라 반응이 다릅니다. 교안은 이를 세 줄로 정리해요.
부타자생 — 부모 타입으로 자식을 생성: D1_Animal a = new D1_Tiger();
덕분에 매개변수를 부모 타입으로 선언한 메서드 하나(moveAnimal(D1_Animal animal))가 사람·호랑이·독수리를 모두 받습니다.
📎 교안 p.100~102 「메모리 구조 · Polymorphism」, p.105 「참조타입 형 변환」
② 형 변환 — 올릴 땐 자동, 내릴 땐 확인하고
자식 → 부모 타입(업캐스팅)은 자동입니다. 반대로 부모 타입 변수에 든 객체의 자식 전용 메서드(eat())를 쓰려면 (D1_Human) animal처럼 다운캐스팅을 직접 써야 해요. 실제 객체가 그 타입이 아니면 실행 중에 ClassCastException이 나므로, if (obj instanceof D1_Human)으로 먼저 확인합니다.
📎 교안 p.102 「Polymorphism」, p.105
③ 추상 클래스 — 반쯤 만든 설계도
abstract 메서드는 몸통 { }가 없는 약속("display는 꼭 있어야 하는데, 어떻게 할지는 자식이 정해")입니다. 이런 메서드가 하나라도 있으면 클래스도 abstract가 되고, new로 직접 만들 수 없어요. 공통 기능(전원 켜기·끄기)은 부모가 구현해서 물려주고, 다른 부분만 자식이 채웁니다. 자식이 추상 메서드를 다 채우지 못하면 그 자식도 abstract로 남아야 해요(10일차 D2_NoteBook).
📎 교안 p.110~112 「Abstract(추상클래스)」
④ 인터페이스 — "~할 수 있다"는 기능 약속
추상 클래스가 "A는 B다"(is-a, 공통 필드·구현 공유)라면, 인터페이스는 "A는 B를 할 수 있다"(can-do, 기능 목록)입니다. 클래스는 부모를 하나만 extends하지만 인터페이스는 여러 개를 implements할 수 있어요.
인터페이스는 new로 만들 수 없지만, new 인터페이스() { … }처럼 그 자리에서 구현하며 객체를 만드는 익명 클래스는 됩니다. 11일차 D2_Calc(인터페이스) → D2_Calculator(add·substract만 구현한 추상 클래스) → D2_CalculatorChild(나머지 구현)처럼 둘을 겹쳐 쓰기도 해요.
📎 교안 p.113~117 「Interface」
⚠️ 교안 · 수업 코드 표현 바로잡기
교안 p.113은 「private는 사용이 불가능하기 때문에, public 또는 protected 접근 제한자를 사용」이라고 하지만, 인터페이스 멤버에 protected는 쓸 수 없습니다. 메서드는 생략해도 public이고, Java 9부터 private 메서드는 허용돼요(11일차 D1_InterfaceTest의 private void test6()이 바로 그것).
교안 p.113의 「인터페이스는 여러 개의 인터페이스를 상속할 수 있다 ex) Ipenguin implements IAnimal, IFish…」에서, 인터페이스끼리 물려받을 때는 implements가 아니라 interface IPenguin extends IAnimal, IFish처럼 extends를 씁니다. implements는 클래스가 인터페이스를 구현할 때예요.
교안 p.117 표의 「default 메서드만 구현 가능 (Java 8+)」 — 몸통을 가질 수 있는 건 default뿐 아니라 static(Java 8+)과 private(Java 9+)도 있습니다.
11일차 D2_CalculatorMain의 (D2_CalculatorChild) calc2; // 형변환 -> 업캐스팅은 부모 타입을 자식 타입으로 바꾸는 것이니 다운캐스팅입니다.
예제 — 수업 코드로 확인하기
Animal — 메서드 하나로 동물 셋 움직이기
이 코드는 다형성의 3원리(부타자생·부타자참·부메자호)를 한 파일에서 확인하고, 다형성을 안 쓰면 동물마다 메서드를 따로 만들어야 한다는 걸 비교해 보려고 만든 실습입니다. Human·Tiger·Eagle은 모두 move()를 오버라이딩했고, eat()은 자식에만 있어요.
JAVAD1_AnimalMain.java (day10) — 다형성
// 부모의 타입으로 자식을 생성
D1_Animal human = new D1_Human();
human.move(); // 자식에서 구현한 메서드가 실행(오버라이딩)
// human.eat(); //설계도에 공개된 메서드만 사용가능
// 자식에서 단독으로 구현한 메서드를 사용하려면 자식타입으로 형변환해야 된다.
D1_Human humanEat = (D1_Human) human;
humanEat.eat(); // 자식에서 단독으로 구현한 메서드
// …(생략)
// 1.부모의 타입으로 자식을 생성한다.
D1_Animal animalH = new D1_Human();
D1_Animal animalT = new D1_Tiger();
D1_Animal animalE = new D1_Eagle();
moveAnimal(animalH);
moveAnimal(animalT);
moveAnimal(animalE);
// …
public static void moveAnimal(D1_Animal animal) {
animal.move(); // 하나의 타입으로 여러 형태를 나타낼 수 있다.(다형성)
}
// 다형성을 활용하지 않았을 경우
public static void moveAnimal(D1_Human human) { human.move(); }
public static void moveAnimal(D1_Tiger tiger) { tiger.move(); }
public static void moveAnimal(D1_Eagle eagle) { eagle.move(); }
// …(생략: Object 매개변수 + instanceof 버전)
👀 관찰 포인트 — 아래 세 줄은 사람이 걷습니다. · 호랑이는 네발로 뜁니다. · 독수리는 하늘을 납니다.를 찍습니다. 재미있는 건 어느 moveAnimal이 불리는가예요. 오버로딩된 메서드 중 무엇을 부를지는 컴파일할 때 변수 타입(D1_Animal)으로 정해지므로 세 번 모두 moveAnimal(D1_Animal)이 선택되고, 그 안의 animal.move()가 실행할 때 실제 객체를 보고 각자의 move를 부릅니다. 오버로딩은 컴파일 시점, 오버라이딩은 실행 시점 — 이 차이가 다형성의 핵심입니다.
Computer — 추상 클래스 3단 계층
이 코드는 "공통 기능은 부모가 구현하고, 다를 수밖에 없는 기능은 추상 메서드로 자식에게 떠넘긴다"를 보여 주려고 만든 실습입니다. 노트북은 화면은 정할 수 있지만 키보드는 기종마다 달라서, 한 단계 더 내려가서야 완성돼요.
public abstract class D2_Computer {
public void turnOn() {
System.out.println("전원을 켭니다");
}
// …(생략: turnOff)
public abstract void display(); // 추상메서드
public abstract void typing(); // 추상메서드
}
public abstract class D2_NoteBook extends D2_Computer {
@Override
public void display() {
System.out.println("노트북 화면 출력");
}
// notebook마다 키보드 유형이 다르기때문에 여기서 명확하게 구현할 수 없다.
@Override
public abstract void typing();
}
public class D2_MyNoteBook extends D2_NoteBook {
@Override
public void typing() {
System.out.println("MyNoteBook typing기능입니다.");
}
}
// D2_ComputerMain
// D2_Computer com = new D2_Computer(); //불가능
D2_Computer MynoteBook = new D2_MyNoteBook();
MynoteBook.turnOn();
MynoteBook.display();
MynoteBook.typing();
MynoteBook.turnOff();
👀 관찰 포인트 — 출력은 전원을 켭니다 → 노트북 화면 출력 → MyNoteBook typing기능입니다. → 전원을 끕니다. 네 메서드가 세 클래스에서 하나씩 왔어요(turnOn은 Computer, display는 NoteBook, typing은 MyNoteBook). 추상 메서드가 남은 Computer·NoteBook은 new가 막혀 있고, 모든 약속을 채운 MyNoteBook만 객체가 됩니다.
인터페이스의 새 기능과 익명 클래스
이 코드는 인터페이스에 넣을 수 있는 다섯 가지(상수·추상·default·private·static)를 한 파일에 모아 보고, 클래스를 따로 만들지 않고 그 자리에서 구현하는 익명 클래스를 써 보려고 만든 실습입니다.
JAVAD1_InterfaceTest.java (day11) — 인터페이스 구성 요소
public interface D1_InterfaceTest {
// 맴버필드 선언 -> 자동으로 상수가 됨
public int b = 50;
public static final int A = 30;
// 추상메서드: 그냥 작성해도 자동 추상메서드가 된다.
public void test1();
public abstract int test2();
public int test3();
// default 메서드
public default void test5() {
System.out.println("인터페이스에서 기능을 구현할 수 있는 메서드");
}
// private 메서드: 현재 Interface 내부에서 기능을 구현 -> 내부에서만 접근
private void test6() {
System.out.println("인터페이스 내부에서만 사용가능한 메서드.");
}
// static 메서드: 독립적인 기능을 제공할 떄
static void test7() {
System.out.println("인터페이스에서 독립적인 기능을 제공하는 메서드.");
}
}
JAVAD1_InterfaceMain.java (day11) — 구현 클래스와 익명 클래스
D1_InterfaceChild it = new D1_InterfaceChild();
it.test1();
it.test2();
it.test5();
D1_InterfaceTest.test7(); // static 메서드
// 익명 클래스 -> 이름이 없는 클래스 정의 -> 재활용X
// interface는 자체적으로 객체 생성X
D1_InterfaceTest it2 = new D1_InterfaceTest() {
@Override
public void test1() {
System.out.println("인라인 익명클래스에서 구현한 test1");
}
// …(생략: test2, test3, test5도 구현)
};
👀 관찰 포인트 — 출력은 메서드구현해야 함(test1) → 인터페이스의 디폴트 메서드를 오버라이딩 할 수 있다.(Child가 재정의한 test5) → 인터페이스에서 독립적인 기능을 제공하는 메서드.(test7) 세 줄뿐입니다. test2는 0을 돌려주기만 하고, 익명 클래스 it2는 객체만 만들고 메서드를 부르지 않았으니 아무것도 안 찍혀요. static 메서드 test7은 객체가 아니라 인터페이스 이름으로 부른다는 점도 확인하세요.
핵심 정리
다형성: 부모 타입 변수에 자식 객체 → 부를 수 있는 메서드는 변수 타입이, 실행되는 메서드는 실제 객체가 정한다.
자식 전용 메서드는 instanceof 확인 후 다운캐스팅해서 쓴다. 틀리면 ClassCastException.
추상 클래스: 공통 구현 + 추상 메서드로 구현 강요, new 불가, 단일 상속.
인터페이스: 상수 + 기능 약속, 여러 개 implements, Java 8 default/static, Java 9 private.
익명 클래스: 이름 없이 그 자리에서 상속·구현하며 객체를 딱 하나 만든다.
확인 문제D1_Animal a = new D1_Tiger(); a.eat();은 왜 컴파일 에러인가요?
변수 타입 D1_Animal(설계도)에는 eat()이 없기 때문입니다. 컴파일러는 변수 타입만 보고 판단해요. ((D1_Tiger) a).eat();처럼 다운캐스팅해야 합니다.
D1_Animal x = new D1_Tiger(); D1_Human h = (D1_Human) x;는 어떻게 되나요?
컴파일은 되지만 실행 중에 ClassCastException이 납니다. 실제 객체가 호랑이라 사람으로 바꿀 수 없어요. x instanceof D1_Human으로 먼저 확인해야 합니다.
D2_NoteBook에서 abstract를 빼면 어떻게 되나요?
typing()이 여전히 추상 메서드(몸통 없음)라서 컴파일 에러가 납니다. 추상 메서드가 남아 있는 클래스는 abstract여야 해요.
인터페이스의 int b = 50;에 다른 값을 대입할 수 있나요?
없습니다. 인터페이스 필드는 자동으로 public static final 상수라서 값을 바꾸려 하면 컴파일 에러예요.
다음 장으로
익명 클래스는 "클래스 안에 들어 있는 클래스"의 한 종류였어요. 14장에서는 중첩 클래스 4종을 정리하고, 타입을 나중에 정하는 제네릭, 그리고 지금까지 배운 private 생성자·인터페이스·다형성을 엮은 싱글턴 + 팩토리 패턴으로 마방진 3종을 하나로 묶습니다.
중첩 클래스 4종(static·인스턴스·지역·익명)을 만드는 법과, 각각 바깥 클래스의 무엇에 접근할 수 있는지 말할 수 있다.
제네릭 <T>가 왜 형변환 없이 안전한지, List<Integer>가 List<Number>가 아닌 이유와 와일드카드를 설명할 수 있다.
싱글턴 + 팩토리 + 인터페이스로 마방진 3종을 묶은 구조를 따라가고, 6마방진 코드의 버그를 찾을 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
13장에서 익명 클래스를 처음 봤어요. 클래스 안에 클래스를 두는 방법은 그 말고도 세 가지가 더 있습니다. 제네릭은 바로 다음에 나올 컬렉션(List<String>)의 꺾쇠 괄호를 이해하는 열쇠예요. 마지막으로 5일차 싱글턴(private 생성자 + static), 13장의 인터페이스와 다형성을 조립해서 "숫자만 넣으면 알맞은 마방진을 골라 주는 자판기"를 만듭니다. 2부 객체지향의 총정리인 셈이에요.
개념
① 중첩 클래스 4종 — 어디에 두느냐에 따라 달라진다
한 클래스에서만 쓰는 도우미 클래스를 그 안에 넣어 두면 관련 코드가 한곳에 모이고 바깥에 덜 드러납니다.
정적(static) 중첩 클래스 — 바깥 객체 없이 new StaticInnerClass(…). 바깥의 static 멤버만 바로 쓸 수 있음.
인스턴스 내부 클래스 — 바깥 객체에 붙어 있어서 바깥객체.new InnerClass(…)로 생성. 바깥의 인스턴스 필드까지 모두 접근.
지역 내부 클래스 — 메서드 안에 선언, 그 메서드에서만 사용. 메서드의 지역변수는 final 또는 effectively final(한 번 대입 후 안 바뀌는 변수)만 쓸 수 있음.
익명 클래스 — 이름 없이 그 자리에서 인터페이스를 구현하거나 클래스(추상 클래스 포함)를 상속하며 객체를 하나 만듦.
📎 교안 p.118~119 「중첩클래스」
② 제네릭 — 상자에 라벨 붙이기
라벨 없는 상자(Object)에 아무거나 넣으면 꺼낼 때마다 "이게 뭐였지?" 하고 형변환해야 하고, 잘못 넣어도 실행해 봐야 압니다. class D3_GBox<T>의 T는 "나중에 정할 타입" 자리예요. D3_GBox<String>으로 만들면 그 상자에는 String만 들어가고, 꺼낼 때 형변환이 필요 없으며, 다른 타입을 넣으면 컴파일할 때 바로 막힙니다. 타입 자리에는 참조타입만 올 수 있어서 int 대신 Integer를 씁니다.
주의할 점: Integer가 Number의 자식이어도 List<Integer>는 List<Number>의 자식이 아닙니다(그걸 허용하면 Integer 리스트에 Double을 넣을 수 있게 되니까요). 그래서 "Number나 그 자식이 든 아무 리스트"를 받고 싶을 땐 와일드카드 List<? extends Number>를 씁니다. 이건 꺼내기(Number로 읽기)만 되고 넣기는 막혀요. 반대로 <? super Integer>는 Integer를 넣을 수 있습니다.
📎 교안 p.120~125 「Generic」
③ 디자인 패턴 — 자주 쓰는 설계의 이름
싱글턴 — 생성자를 private으로 막고, static getInstance()가 처음 한 번만 만들어 같은 객체를 돌려줌. 세상에 하나면 충분한 객체(공장, 설정)에 사용.
팩토리 — 어떤 클래스를 new할지 고르는 일을 한 메서드에 모음. 반환 타입을 인터페이스로 하면 쓰는 쪽은 실제 클래스를 몰라도 됨(13장 다형성).
실습과제 p.18의 클래스 다이어그램이 이 구조입니다: Interface_Magic(make·magicPrint) ← MagicSquare(추상 클래스, 출력·합계·검증 구현) ← Odd/Even/Six(각자 make 구현), 그리고 MagicFactory가 이들을 골라 만들어 줍니다.
6마방진 makeA()의 if (i == 2) — "A영역의 가운데 줄만 한 칸 옆으로 민다"는 규칙인데, 가운데 줄 번호를 2로 고정해 버렸습니다. A영역은 n/2줄이라 가운데 줄은 n / 4번이고, 이게 2가 되는 건 n = 10일 때뿐이에요. 그래서 10×10은 모든 합이 505로 맞지만, 6×6은 가로·세로 합(111)은 맞아도 대각선 합이 84와 165로 달라 isCheck()가 false가 되고, 14×14도 대각선이 틀립니다. 원본 주석("i인덱스의 n/4이 되는 위치")대로 if (i == n / 4)로 고치면 6·10·14 모두 마방진이 됩니다.
12일차 D3_GBox.printList1의 (Integer) list.get(0)은 List<? extends Number>에 Double 리스트가 들어오면 ClassCastException이 납니다. 와일드카드로 받았다면 Number n = list.get(0);처럼 상한 타입으로 읽는 게 안전해요.
11일차 D1_MagicUtil.magicRun()의 주석은 이를 "템플릿 메서드"라고 부르는데, 디자인 패턴의 템플릿 메서드는 보통 부모 클래스의 메서드가 순서를 정하고 자식이 단계를 채우는 구조를 말합니다. magicRun은 "어떤 마방진이든 make → magicPrint 순서로 실행하는 공통 도우미"로 이해하면 충분합니다.
예제 — 수업 코드로 확인하기
중첩 클래스 — 만드는 법과 접근 범위
이 코드는 중첩 클래스 4종을 한 파일에 만들어 보고, 각각 바깥의 어떤 필드를 쓸 수 있는지 비교하려고 만든 실습입니다.
JAVAD1_NestedClassTest.java (day12) — 중첩 클래스 4종
public class D1_NestedClassTest {
public int a = 5;
public int b = 10;
public static int aa = 7;
public static int bb = 3;
// 정적 내부 클래스: 독립적으로 사용 가능
static class StaticInnerClass {
// …(생략: message 필드, 생성자)
public int getResult() {
int result = aa + bb; // static 필드만 접근 가능하다.
return result;
}
}
// 인스턴스 내부 클래스
class InnerClass {
// …(생략)
public int getResult() {
int result = a + b;
return result;
}
}
public void nestedMethod() {
int c = 5;
class LocalInnerClass { // 지역 내부 클래스
// …(생략)
public int getLic() {
int ss = c;
return ss;
}
}
// c=50; 값이 변경되는 코드가 있다면 오류발생 -> 번경여지가 없는 변수여야 한다.
// …
}
public static void main(String[] args) {
StaticInnerClass sic = new StaticInnerClass("정적내부클래스");
System.out.println(sic.getResult());
// 인스턴스 내부 클래스: 외부클래스를 먼저 객체 생성하고, 내부클래스를 생성해야 사용할 수 있다.
InnerClass inner = new D1_NestedClassTest().new InnerClass("내부클래스");
System.out.println(inner.getResult());
// 익명클래스
MagicSquare magic = new MagicSquare() {
@Override
public void make() { }
@Override
public void magicPrint() { }
};
magic.make();
magic.magicPrint();
}
}
👀 관찰 포인트 — 출력은 10(7+3)과 15(5+10). static 중첩 클래스는 바깥 객체가 없으니 인스턴스 필드 a, b를 못 쓰고, 인스턴스 내부 클래스는 new D1_NestedClassTest().new …로 바깥 객체를 먼저 만들어야 합니다. 익명 클래스가 인터페이스가 아니라 추상 클래스 MagicSquare를 상속한 점도 눈여겨보세요. 둘 다 됩니다.
제네릭 상자와 와일드카드
이 코드는 교안 p.121의 Box<T>를 직접 만들어 String 상자·Integer 상자로 써 보고, p.125의 와일드카드가 왜 필요한지 확인하려고 만든 실습입니다.
JAVAD3_GBox.java (day12) — 제네릭 클래스
public class D3_GBox<T> {
private T item;
public void set(T item) {
this.item = item;
}
public T get() {
return item;
}
public static void main(String[] args) {
D3_GBox<String> strBox = new D3_GBox<>();
strBox.set("Hello~~~");
System.out.println(strBox.get());
D3_GBox<Integer> intBox = new D3_GBox<>();
intBox.set(1000);
System.out.println(intBox.get());
// 와일드카드: <? extends T> T와 T의 하위타입들: 읽기전용
List<? extends Number> numbers = new ArrayList<Integer>();
// numbers.add(100);// 추가,수정작업 X
// …(생략: num 리스트에 100, 101, 102 추가, numbers = num)
// printList2(num);
printList1(num);
}
// List에 저장되면 타입간에 계층구조가 깨짐 -> 와일드 카드로 처리하면 계층구조 유지
public static void printList1(List<? extends Number> list) {
Integer i = (Integer) list.get(0);
System.out.println(i);
}
public static void printList2(List<Number> list) {
}
}
👀 관찰 포인트 — Hello~~~, 1000, 100, 100이 찍힙니다. strBox.get()을 String 변수에 받을 때 형변환이 없죠. printList2(num)이 주석 처리된 이유가 "계층구조가 깨진다"는 주석의 뜻이에요 — List<Integer>는 List<Number> 자리에 못 들어가서 컴파일 에러가 납니다. ? extends Number로 받은 printList1은 됩니다.
마방진 자판기 — 싱글턴 + 팩토리 + 인터페이스
14일차는 홀수·4의 배수·6마방진을 모두 완성한 뒤, "사용자가 숫자만 넣으면 알맞은 마방진 객체를 골라 주는" 구조로 묶었습니다. 이 코드는 싱글턴과 팩토리 패턴, 그리고 인터페이스 타입으로 받는 다형성이 실제로 어떻게 맞물리는지 보여 주려고 만든 실습입니다. (파일은 day11/day11_2 패키지에 있어요.)
JAVAD2_MagicFactory.java (day11_2) — 싱글턴 + 팩토리
public class D2_MagicFactory {
// singleton pattern 적용
private static D2_MagicFactory magicFactory;
private D2_MagicFactory() {
}
public static D2_MagicFactory getInstance() {
if (magicFactory == null) {
magicFactory = new D2_MagicFactory();
}
return magicFactory;
}
// Factory pattern
public Interface_Magic factory() {
Interface_Magic magic = null;
// …(생략: Scanner로 num 입력받기)
if (num < 3) {
System.out.println("3이상 숫자를 입력하세요");
} else if (num % 2 == 1) {
magic = new OddMagicSquare(num);
} else if (num % 4 == 0) {
magic = new EvenMagicSquare(num);
} else if (num % 4 == 2) {
magic = new D1_SixMagicSquare(num);
}
scan.close();
return magic;
}
}
JAVAD1_MagicUtil.java · D3_MagicSquareMain.java (day11_2) — 쓰는 쪽
public class D1_MagicUtil {
public static void magicRun(Interface_Magic magic) {
magic.make();
magic.magicPrint();
}
}
// D3_MagicSquareMain.main
// 메서드를 통해 객체 얻어옴: new 사용 못함
D2_MagicFactory fac = D2_MagicFactory.getInstance();
Interface_Magic magic = fac.factory();
if (magic == null) {
System.out.println("다시입력하세요");
} else {
D1_MagicUtil.magicRun(magic);
}
JAVAD1_SixMagicSquare.java (day14) — makeA()의 버그 자리
// i인덱스의 n/4이 되는 위치에서 j+1을 해서 3을 채우자
private void makeA() {
int n = magic.length;
for (int i = 0; i < n / 2; i++) {
for (int j = 0; j < n / 4; j++) {
if (i == 2) { // ← n=10에서만 맞음. if (i == n / 4) 로 고쳐야 한다
magic[i][j + 1] = 3;
} else {
magic[i][j] = 3;
}
}
}
}
👀 관찰 포인트 — Main에는 new OddMagicSquare도 if~else로 타입을 가르는 코드도 없습니다. 어떤 객체가 오든 Interface_Magic 타입으로 받아 make()·magicPrint()만 부르면, 실제 객체의 make가 실행돼요(13장 VMI). 3, 4, 10을 넣으면 마방진 증명여부:true가 나오지만 6을 넣으면 false가 나옵니다 — 위 makeA의 i == 2 때문이에요(6×6에서 A영역 가운데 줄은 1번). 그리고 팩토리를 다시 getInstance()로 얻어도 같은 객체가 돌아옵니다.
핵심 정리
중첩 클래스: static(바깥 static만) · 인스턴스(바깥 객체 필요, 전부 접근) · 지역(effectively final 지역변수만) · 익명(그 자리에서 1회).
제네릭 <T>: 타입을 만들 때 정해 형변환 없이 컴파일 시점에 검사. 타입 인자는 참조타입만.
List<Integer> ≠ List<Number>. 받는 쪽을 넓히려면 ? extends(읽기), 넣으려면 ? super.
싱글턴 = private 생성자 + static getInstance. 팩토리 = 생성 선택을 한곳에 모으고 인터페이스 타입으로 반환.
상수처럼 보이는 값을 하드코딩(i == 2)하면 특정 크기에서만 맞는다 — 크기에 따라 변하는 값은 식(n / 4)으로.
확인 문제main에서 new InnerClass("x")로 바로 만들 수 없는 이유는?
인스턴스 내부 클래스는 바깥 객체에 붙어 있어야 하는데, main은 static이라 바깥 객체(this)가 없습니다. new D1_NestedClassTest().new InnerClass("x")처럼 바깥 객체를 먼저 만들어야 해요.
nestedMethod()에서 지역 클래스 선언 뒤에 c = 50;을 넣으면?
컴파일 에러입니다. 지역 클래스가 쓰는 지역변수 c는 effectively final(한 번 대입 후 안 바뀜)이어야 하는데, 다시 대입하면 그 조건이 깨져요.
factory()의 반환 타입을 OddMagicSquare가 아니라 Interface_Magic으로 한 이유는?
입력에 따라 Odd·Even·Six 중 무엇이든 돌려줘야 하기 때문입니다. 셋이 모두 구현한 공통 타입으로 반환하면, 쓰는 쪽은 실제 클래스를 몰라도 make·magicPrint를 똑같이 부를 수 있어요(다형성).
6을 입력했을 때 isCheck()가 false인 이유와 고치는 방법은?
makeA()가 가운데 줄을 i == 2로 고정해서, 6×6(A영역 3줄, 가운데는 1번 줄)에서는 엉뚱한 줄을 밀기 때문입니다. 가로·세로 합은 맞아도 대각선이 틀려요. if (i == n / 4)로 바꾸면 됩니다.
다음 장으로
여기까지가 2부 객체지향입니다. 다음 장부터는 교안 p.126의 자바 프로그래밍 활용으로 넘어가, 크기가 고정된 배열의 한계를 넘는 컬렉션 프레임워크(List<E>, Map<K, V>)를 배웁니다. 이 장에서 익힌 제네릭 꺾쇠 괄호가 거기서 매일 나와요.
add·get·contains·put·keySet·Iterator로 값을 넣고 꺼낼 수 있다.
내가 만든 클래스를 컬렉션에 담을 때 equals()·hashCode()를 왜 재정의해야 하는지 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
배열은 만들 때 칸 수를 정해야 하고, 나중에 늘릴 수 없었어요. 실제 프로그램에서는 회원이 몇 명일지, 글이 몇 개일지 미리 알 수 없죠. 컬렉션은 필요한 만큼 알아서 늘어나는 그릇입니다. 바로 앞에서 배운 제네릭(<T>)이 여기서 처음 제대로 쓰여요 — List<String>은 "String만 담는 리스트"라는 뜻입니다. 그리고 객체 단원에서 배운 equals()·hashCode() 재정의가 실제로 왜 필요한지 이 장의 카드 실습에서 처음 체감하게 됩니다.
개념
① 세 가족 — List · Set · Map
List는 번호표가 붙은 줄서기예요. 순서가 있고, 같은 값이 여러 번 들어가도 되고, get(0)처럼 번호(인덱스)로 꺼냅니다. Set은 출석부 도장 같아서 같은 값은 한 번만 들어가고, 인덱스가 없어 get(i)가 없습니다. Map은 사물함처럼 이름표(key)와 내용물(value)을 한 쌍으로 저장해요. key는 중복될 수 없어서 같은 key로 다시 put하면 새 값으로 덮어씁니다(value는 중복 가능).
변수 타입은 인터페이스(List, Set, Map)로 쓰고, new 뒤에 실제 구현을 고릅니다. ArrayList는 배열 기반이라 번호로 읽기가 빠르고, 중간에 끼워 넣으면 뒤 원소를 다 밀어야 해서 느려요. LinkedList는 그 반대입니다. Set·Map은 이름 앞부분으로 성격을 알 수 있어요 — Hash는 순서 없음·가장 빠름, LinkedHash는 넣은 순서 유지, Tree는 자동 정렬. Vector는 Java 1.0 시절 클래스라 지금은 잘 쓰지 않고, 여러 스레드가 함께 쓸 리스트가 필요하면 Collections.synchronizedList(...)로 감쌉니다.
📎 교안 p.131~134 「ArrayList vs LinkedList / Vector」, p.138 「HashSet vs LinkedHashSet vs TreeSet」, p.141 「HashMap, LinkedHashMap, TreeMap」
③ "같은 값"은 누가 판단하나 — equals()와 hashCode()
컬렉션은 "이미 들어 있나?"를 객체의 equals()로 물어봅니다. List.contains()는 처음부터 끝까지 하나씩 equals()로 비교해요. HashSet·HashMap은 더 빠르게 하려고 먼저 hashCode()로 몇 번 칸에 있을지 계산하고, 그 칸 안에서만 equals()로 확인합니다. 그래서 내가 만든 클래스는 "내용이 같으면 같다"는 equals()를 재정의하고, equals가 같은 두 객체는 hashCode도 같게 맞춰 줘야 해요. 반대로 hashCode가 같다고 같은 객체는 아닙니다(교안 p.137의 예처럼 서로 다른 key가 같은 칸에 들어갈 수 있어요).
📎 교안 p.137 「SET/Map 구조」
⚠️ 교안·수업 코드 표현 바로잡기
교안 p.133은 ArrayList를 「비동기(asynch)」라고 하지만, 정확히는 "동기화되지 않음(스레드 안전하지 않음)"입니다. 비동기(asynchronous)는 "기다리지 않고 다음 일을 한다"는 전혀 다른 뜻이에요. 여러 스레드가 동시에 고치면 망가질 수 있다는 의미입니다(19장).
수업 코드 D2_CardCase의 주석 「contains()는 해시코드로 비교해서…」도 부정확합니다. List.contains()는 equals()만 사용하고 hashCode는 보지 않아요. equals를 재정의하지 않으면 Object의 기본 equals(= ==, 주소 비교)가 쓰여서 "내용이 같아도 다르다"고 판단하는 것입니다. hashCode는 HashSet·HashMap에 넣을 때 쓰입니다.
예제 — 수업 코드로 확인하기
예제 1. Set은 중복을 버리고, Map은 key로 찾는다
이 코드는 Set의 "중복 불가"와 Map의 "key로 꺼내기", 그리고 둘을 끝까지 훑는 두 가지 방법(향상된 for · Iterator)을 보여 주려고 만든 실습입니다.
JAVAD1_SetTest.java — 중복 제거
Set<String> set = new HashSet<>();
set.add("한");
set.add("국");
set.add("경");
set.add("제");
set.add("제"); // 두 번 넣어도 하나만 남는다
// Iterator
Iterator<String> iter = set.iterator(); // Set -> Iterator 객체로 변환
while (iter.hasNext()) { // 값을 확인한다
String str = iter.next(); // 값을 꺼낸다
System.out.println(str);
}
// 꺼내는 다른 방법: List로 변환 -> 원하는값만 가져올 수 있다.
List<String> list = new ArrayList<>(set);
System.out.println(list.get(2));
JAVAD1_MapTest.java — key로 꺼내기
Map<String, String> map = new HashMap<>();
map.put("하나", "한경");
map.put("둘", "닷컴");
map.put("셋", "교육센터");
map.put("셋", "교육센터"); // key는 중복할 수 없다.
System.out.println(map.get("하나") + "," + map.get("둘"));
// Map에서 일괄적으로 데이터를 가져오려면
Set<String> setKeyMap = map.keySet(); // key들만 Set에 담아 반환
for (String s : setKeyMap) {
System.out.println(map.get(s));
}
👀 관찰 포인트 — Set에는 4개만 들어 있습니다(set.size()는 4). 출력 순서가 넣은 순서와 다를 수 있다는 점도 보세요. HashSet은 순서를 보장하지 않으니 list.get(2)가 어떤 글자일지도 미리 장담할 수 없어요. 넣은 순서가 필요하면 LinkedHashSet을 씁니다. Map은 map.get("하나")처럼 번호 대신 key로 꺼내고, 전체를 돌 때는 keySet()으로 key 목록을 먼저 얻습니다.
예제 2. 카드 52장을 중복 없이 만들기
이 코드는 "내가 만든 객체를 List에 넣고 중복 검사를 하려면 equals()를 재정의해야 한다"는 것을 보여 주려고 만든 실습입니다. 무작위로 카드를 한 장씩 뽑아, 이미 있으면 버리고 없으면 넣기를 52장이 찰 때까지 반복해요.
JAVAD2_Card.java — 카드 한 장 (equals·hashCode 재정의)
public class D2_Card {
public static final String[] DECK = { "◆", "♥", "♣", "♠" };
public static final String[] STECK = { "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K" };
private String card; // "♦2"
// …(생략: 생성자, init()에서 무작위 그림+숫자 조합, getCard(), toString())
// Card객체 내부에 멤버필드인 card끼리 비교하는 기능으로 재정의
@Override
public boolean equals(Object obj) {
boolean isS = false;
D2_Card ca = (D2_Card) obj;
if (this.card.equals(ca.getCard())) {
isS = true;
}
return isS;
}
// equals()를 오버라이딩하면 hashcode()도 오버라이딩해야 됨
@Override
public int hashCode() {
return card.hashCode() + 137;
}
}
JAVAD2_CardCase.java — 52장 채우기
private List<D2_Card> cards;
private static final int NUMOFCARDS = D2_Card.DECK.length * D2_Card.STECK.length; // 4 × 13 = 52
public void shuffle() {
int i = 0;
while (true) {
D2_Card cc = new D2_Card(); // 카드 한장 생성
if (!cards.contains(cc)) { // 내부에서 cc.equals(이미 있는 카드)로 하나씩 비교
cards.add(cc);
i++;
}
if (i == NUMOFCARDS) {
break;
}
}
}
👀 관찰 포인트 — D2_CardMain을 실행하면 52장이 10장씩 줄바꿈되어 찍히고, 같은 카드가 두 번 나오지 않습니다. [♥7]처럼 보이는 건 toString()을 재정의했기 때문이에요. 이제 D2_Card의 equals()를 지우고 다시 실행해 보세요. 매번 new로 만든 카드는 주소가 다르니 contains()가 항상 false가 되어, 중복 카드가 섞인 52장이 금방 만들어집니다.
핵심 정리
List = 순서 O · 중복 O · 인덱스, Set = 중복 X · 인덱스 X, Map = key-value 쌍 · key 중복 X(같은 key면 덮어씀).
선언은 인터페이스로(List<String> list = new ArrayList<>();), 구현은 상황에 맞게 고른다. Hash=빠름·순서 없음, LinkedHash=넣은 순서, Tree=정렬.
contains()·HashSet·HashMap의 "같다"는 equals()가 결정한다. 해시 계열은 hashCode()로 칸을 먼저 찾으므로 둘을 함께 재정의한다.
ArrayList는 "비동기"가 아니라 동기화되지 않은(스레드 안전하지 않은) 리스트다.
확인 문제D1_MapTest에서 map.put("셋", …)을 두 번 했습니다. map.size()는 얼마일까요?
3입니다. key "셋"은 하나뿐이고 두 번째 put은 값을 덮어쓸 뿐 항목을 늘리지 않습니다.
카드 52장을 List 대신 HashSet<D2_Card>로 모은다면 shuffle()을 어떻게 더 짧게 쓸 수 있을까요?
while (set.size() < 52) { set.add(new D2_Card()); }처럼 쓰면 됩니다. Set이 중복을 알아서 버리니 contains() 검사가 필요 없어요. 단, D2_Card가 equals와 hashCode를 둘 다 재정의해 두었기 때문에 가능한 일입니다. 그리고 HashSet이라 꺼내는 순서는 정해져 있지 않습니다.
D2_Card에서 equals()는 그대로 두고 hashCode() 재정의만 지운 뒤 HashSet에 담으면 어떻게 될까요?
같은 그림·숫자의 카드라도 hashCode(기본 hashCode는 객체마다 대개 다름)가 달라 다른 칸으로 가므로 equals 비교까지 가지 못해 중복이 들어갈 수 있습니다. List의 contains()는 hashCode를 보지 않아서 문제가 안 드러났던 거예요.
다음 장으로
list.get(10)인데 원소가 3개뿐이면? 숫자로 바꿀 수 없는 글자를 Integer.parseInt()에 넣으면? 프로그램은 실행 중에 이런 사고를 만납니다. 16장에서는 사고가 나도 프로그램이 멈추지 않게 하는 예외 처리를 배워요.
throw(던지기)와 throws(떠넘긴다고 선언)를 구분하고, 나만의 예외 클래스를 만들 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
지금까지는 에러가 나면 빨간 글씨와 함께 프로그램이 그냥 멈췄어요. 사용자가 숫자 칸에 글자를 넣었다고 프로그램 전체가 꺼지면 곤란하죠. 그리고 바로 다음에 배울 파일 입출력(18장)과 네트워크(20장)는 "파일이 없다", "연결이 끊겼다" 같은 사고가 늘 일어날 수 있어서, 자바가 아예 예외 처리를 하지 않으면 컴파일을 안 해 줍니다. 그래서 지금 배워 둬야 해요.
개념
① try · catch · finally — 사고 대비 3단 구조
try에는 사고가 날 수도 있는 코드를, catch에는 사고가 났을 때 할 일을, finally에는 사고가 나든 안 나든 꼭 할 일(파일 닫기 등)을 씁니다. try 안에서 예외가 나면 그 아래 줄은 건너뛰고 맞는 catch로 바로 갑니다. catch는 위에서부터 차례로 검사하므로 구체적인 예외(자식)를 먼저, Exception(부모)을 맨 뒤에 둡니다. 예외를 잡으면 프로그램은 멈추지 않고 try-catch 다음 줄부터 계속 실행돼요.
📎 교안 p.142 「예외 처리 (1/2)」
② 예외 가계도 — Checked와 Unchecked
모든 예외의 조상은 Throwable이고, 그 아래 Error(메모리 부족처럼 프로그램이 손쓸 수 없는 것)와 Exception이 있습니다. Exception 중 RuntimeException과 그 자식들(NumberFormatException, ArrayIndexOutOfBoundsException, NullPointerException …)은 대개 코드 실수라서 처리를 강제하지 않아요(Unchecked). 나머지(IOException, SQLException …)는 외부 사정으로 생기는 사고라 컴파일러가 "try-catch로 잡거나 throws로 떠넘겨라"고 강제합니다(Checked). 교안이 java.io·java.net·java.sql을 「3대 Checked Exception」으로 묶은 이유예요.
📎 교안 p.143~145 「예외 처리 (2/2)」「예외 계층」「3대 Checked Exception」
③ throw · throws · 사용자 정의 예외 · try-with-resources
throw new 예외()는 지금 여기서 사고를 일으키는 문장이고, 메서드 선언부의 throws 예외는 "이 메서드는 이 예외를 직접 처리하지 않고 부른 쪽에 떠넘긴다"는 표시입니다. 우리 프로그램만의 규칙(예: 1~10만 허용)을 어겼을 때 쓸 예외는 Exception을 상속해 직접 만들어요. 그리고 Java 7부터는 try (자원 선언) { … }로 쓰면 블록이 끝날 때 close()를 자동으로 불러 줍니다(try-with-resources). 18장 IO 코드는 전부 이 방식이에요.
📎 교안 p.146 「throw / throws 절」「사용자 예외처리」
⚠️ 교안 표현 바로잡기
교안 p.142는 「문법을 틀리게 작성하면 컴파일 과정에서 예외가 발생」한다고 하지만, 그건 컴파일 에러이지 예외가 아닙니다. 예외는 프로그램이 실행되는 도중에 생기는 사고(객체)예요.
교안 p.145의 「(Checked Exception은) 컴파일 할 때 발생한다」도 정확히는 "컴파일할 때 처리했는지 검사받는다"입니다. 파일이 없다는 사고 자체는 실행 중에 일어나요. 또 p.144 그림의 Java.lang.CheckedException이라는 클래스는 실제로 없습니다. Checked는 "RuntimeException 계열이 아닌 Exception"을 부르는 분류 이름입니다.
p.146 예제는 piblic, TextException/TestException 같은 오타가 섞여 있어 그대로는 컴파일되지 않습니다. 수업 코드 D1_UserException을 기준으로 보세요.
예제 — 수업 코드로 확인하기
예제 1. 여러 개의 catch와 finally
이 코드는 예외 종류마다 다른 catch로 가고, finally는 어떤 경우에도 실행되며, 예외를 잡으면 프로그램이 계속 돈다는 것을 보여 주려고 만든 실습입니다.
JAVAD1_Exception.java — exTest2()
public static void exTest2(String s) {
int i = 0;
String ss = "스트링";
int[] array = { 1, 2, 3, 4, 5 };
try {
i = Integer.parseInt(s); // ① "오"를 넣으면 여기서 사고
ss = ss.substring(0, 2);
int a = array[5]; // ② 숫자를 넣으면 여기서 사고(인덱스는 0~4)
} catch (NumberFormatException e) {
System.out.println("문자가 숫자형태로 변환되지 않았습니다.");
e.printStackTrace();
} catch (StringIndexOutOfBoundsException e) {
System.out.println("문자열의 범위를 벗어났습니다.");
e.printStackTrace();
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("배열의 범위를 벗어났습니다.");
e.printStackTrace();
} catch (Exception e) { // 부모는 맨 마지막
System.out.println("나머지 모든 예외를 처리한다");
e.printStackTrace();
} finally {
ss = ss.substring(0, 2);
System.out.println(ss);
}
System.out.println("오류발생해도 프로그램은 종료되지 않는다.");
}
👀 관찰 포인트 — exTest2("오")는 ①에서 바로 NumberFormatException catch로 가고, ② 줄은 실행되지 않습니다. exTest2("3")은 ①을 통과하고 ②에서 ArrayIndexOutOfBoundsException catch로 가요. 두 경우 모두 finally가 스트를 찍고, 마지막 줄 "오류발생해도…"까지 출력됩니다.
예제 2. 우리만의 규칙을 어기면 예외를 던진다
이 코드는 사용자 정의 예외를 만들고(extends Exception), throw로 던지고, throws로 떠넘기고, 부른 쪽에서 잡는 전체 흐름을 보여 주려고 만든 실습입니다.
JAVAD1_UserException.java — 나만의 예외
//내가 필요한 Exception 클래스를 만들때는 Exception을 상속받아 생성
public class D1_UserException extends Exception {
public D1_UserException() {
this("UserException 오류 입니다.");
}
public D1_UserException(String msg) {
super(msg); // 부모(Exception)에 메시지 전달 → getMessage()로 꺼낼 수 있다
}
}
public static void main(String[] args) {
try {
UserExceptionTest(12);
} catch (D1_UserException e) {
System.out.println("예외가 발생했습니다");
e.printStackTrace();
}
}
public static void UserExceptionTest(int a) throws D1_UserException {
// 숫자를 받아서 1~10까지의 숫자를 받을 수 있다
if (!(a > 0 && a < 11)) { // 1~10의 범위를 벗어난 숫자를 받는다면
throw new D1_UserException("1부터 10까지의 숫자만 입력가능");
}
}
👀 관찰 포인트 — 12는 범위 밖이라 "예외가 발생했습니다" 다음에 hk.edu20260824.day15.D1_UserException: 1부터 10까지의 숫자만 입력가능으로 시작하는 스택 트레이스가 찍힙니다. D1_UserException이 Exception(Checked)을 상속했기 때문에 throws를 빼거나 main의 try-catch를 빼면 컴파일 에러가 나요. 같은 파일의 exTest4()는 try (InputStream in = new FileInputStream("url"))로 쓴 try-with-resources 예입니다.
핵심 정리
try에서 예외가 나면 남은 줄을 건너뛰고 맞는 catch로 간다. finally는 항상 실행된다.
catch는 자식 예외 먼저, 부모(Exception) 나중. 순서를 거꾸로 쓰면 컴파일 에러.
Checked(IOException·SQLException 등)는 잡거나 throws로 떠넘겨야 컴파일된다. Unchecked(RuntimeException 계열)는 강제하지 않는다.
throw = 지금 던진다, throws = 부른 쪽에 떠넘긴다고 선언. 사용자 정의 예외는 extends Exception + super(메시지).
닫아야 하는 자원은 try ( … ) { }(try-with-resources)로 자동 close.
확인 문제exTest2에서 catch (Exception e)를 맨 위로 올리면 어떻게 될까요?
컴파일 에러가 납니다. Exception이 모든 예외를 먼저 잡아 버리니 아래의 NumberFormatException catch 등은 절대 실행될 수 없어서, 컴파일러가 "이미 잡힌 예외(has already been caught)"라고 알려 줍니다.
D1_UserException이 Exception 대신 RuntimeException을 상속하면 무엇이 달라지나요?
Unchecked 예외가 되어 throws 선언과 main의 try-catch가 없어도 컴파일됩니다. 대신 아무도 잡지 않으면 실행 중에 프로그램이 멈춥니다.
throw와 throws는 각각 어디에 쓰나요?
throw는 메서드 몸통 안에서 throw new 예외(…)로 실제로 예외를 일으킬 때, throws는 메서드 선언부(괄호 뒤)에 "이 예외는 부른 쪽이 처리하라"고 적을 때 씁니다.
다음 장으로
예외까지 익혔으니 이제 자바 8에서 들어온 새 문법을 볼 차례예요. 17장에서는 메서드를 값처럼 짧게 쓰는 람다와, 15장의 컬렉션을 for문 없이 걸러내고 정렬하는 Stream API를 배웁니다.
Stream이 생성 → 중간 연산 → 최종 연산 순서로 흐른다는 것과, 한 번 쓰면 끝난다는 것을 설명할 수 있다.
filter·map·sorted·forEach·collect로 리스트를 가공할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
15장에서 "김씨만 골라 정렬해서 출력"하려면 새 리스트를 만들고, for문으로 고르고, 정렬하고, 다시 for문으로 출력해야 했어요. Stream을 쓰면 이걸 한 줄의 파이프로 씁니다. 그런데 Stream의 filter(…) 괄호 안에는 "어떻게 고를지"라는 동작을 넘겨야 하고, 그 동작을 짧게 적는 문법이 람다예요. 객체 단원에서 배운 인터페이스와 익명 클래스가 람다의 출발점입니다.
개념
① 람다식 — 메서드 하나짜리 인터페이스를 짧게 구현
인터페이스를 쓰려면 원래 클래스를 만들거나 new 인터페이스() { @Override … }처럼 익명 클래스를 써야 했죠. 그런데 인터페이스에 추상 메서드가 딱 하나뿐이면(함수형 인터페이스) 어떤 메서드를 구현하는지 뻔하니까, 매개변수와 몸통만 남겨 (a, b) -> a + b처럼 쓸 수 있어요. 몸통이 한 줄이면 {}와 return을, 매개변수가 하나면 괄호도 생략할 수 있습니다. 매개변수 타입은 인터페이스 선언을 보고 자바가 알아냅니다.
📎 교안 p.147~148 「람다식」「람다식 형태」
② Stream — 생성 → 중간 연산 → 최종 연산
Stream은 컬렉션의 데이터를 컨베이어 벨트에 올려 가공하는 도구예요. list.stream()으로 벨트에 올리고(생성), filter(고르기)·map(바꾸기)·sorted(정렬) 같은 중간 연산을 이어 붙인 뒤, forEach(하나씩 처리)·collect(리스트로 모으기)·count 같은 최종 연산으로 끝냅니다. 중간 연산은 최종 연산이 불릴 때까지 실제로 실행되지 않고(지연 실행), 최종 연산이 끝난 Stream은 다시 쓸 수 없어요. 원본 리스트는 바뀌지 않습니다.
람다가 받은 값을 다른 메서드에 그대로 넘기기만 하면 s -> System.out.println(s) 대신 System.out::println, s -> s.length() 대신 String::length로 쓸 수 있어요(메서드 참조). 테스트 데이터를 만들 때 쓰는 Arrays.asList(…)는 크기가 고정이라 add·remove는 안 되지만 set은 되고, List.of(…)는 완전히 불변이라 set도 안 됩니다.
📎 교안 p.150 「Stream 생성 방법」, p.153 「최종 연산」(forEach(System.out::println))
⚠️ 교안·수업 코드 표현 바로잡기
수업 코드 주석은 병렬 스트림을 「속도가 빠름」, 교안 p.149는 「성능 향상 가능」이라고 합니다. 정확히는 "데이터가 많고 작업이 무거울 때 빨라질 수 있다"예요. 여러 스레드로 나누고 합치는 비용이 있어서 원소 4개짜리 예제에서는 오히려 느릴 수 있고, forEach의 출력 순서도 보장되지 않습니다.
이 장의 Stream(java.util.stream)은 18장의 IO 스트림(java.io)과 이름만 같고 전혀 다른 것입니다. 헷갈리지 않게 18장 첫머리에서 다시 비교해요.
예제 — 수업 코드로 확인하기
예제 1. 같은 덧셈을 세 가지로 쓰기
이 코드는 익명 클래스 → 람다 → 줄인 람다가 결국 같은 일을 한다는 것을 나란히 보여 주려고 만든 실습입니다.
JAVAD1_ILambda.java — 추상 메서드가 하나뿐인 인터페이스
public interface D1_ILambda {
// 메서드를 하나만 선언해서 사용한다.
public int add(int a, int b); // 추상메서드
}
JAVAD1_LabdaTest.java — 세 가지 구현
// 익명클래스방식
D1_ILambda lam = new D1_ILambda() {
@Override
public int add(int a, int b) {
return a + b;
}
};
System.out.println(lam.add(1, 2));
// 람다식 방식
D1_ILambda lam2 = (a, b) -> {
return a + b;
};
System.out.println(lam2.add(1, 2));
// 람다식 방식 간략하게
D1_ILambda lam3 = (a, b) -> a + b;
System.out.println(lam3.add(1, 2));
👀 관찰 포인트 — 세 줄 모두 3을 출력합니다. 람다에서 사라진 것은 new D1_ILambda(), @Override public int add, 매개변수 타입 int예요. 인터페이스에 메서드가 하나뿐이라 "어떤 메서드인지"를 적지 않아도 되는 겁니다.
예제 2. 김씨만 골라 정렬하기 — Stream vs for문
이 코드는 같은 일을 Stream 한 줄과 for문 여러 줄로 각각 해서 비교하고, map으로 값을 바꿔 새 리스트로 모으는 방법을 보여 주려고 만든 실습입니다.
List<String> list = Arrays.asList("김태경", "김상원", "임종서");
Stream<String> streamList = list.stream();
streamList.filter(s -> s.contains("김")).sorted()
.forEach(s -> System.out.println(s));
// Stream객체는 한번 사용하면 끝남 -> 다시 못씀
list.stream().filter(s -> s.startsWith("임")).sorted()
.forEach(System.out::println); // 메서드 참조
// Stream을 사용하지 않고 구현한다면?
List<String> list2 = new ArrayList<>();
for (String s : list) {
if (s.contains("김")) {
list2.add(s);
}
}
Collections.sort(list2);
for (String s : list2) {
System.out.println(s);
}
// map(): 값을 편집해서 새로운 값으로 반환 (원본 데이터를 변경하지 않는다)
List<Integer> listNum = list.stream()
.map(String::length)
.collect(Collectors.toList()); // 반환한 값을 list에 담아서 반환
System.out.println(listNum.toString()); // [3,3,3]
// …(생략: List.of()와 parallelStream() 비교)
👀 관찰 포인트 — 첫 Stream은 김상원, 김태경 순서로 출력됩니다(가나다 정렬). for문 버전도 결과는 똑같지만 줄 수가 세 배쯤 돼요. streamList를 한 번 더 쓰면 IllegalStateException이 나므로 두 번째는 list.stream()으로 새로 만들었습니다. 생략한 뒷부분의 parallelStream()은 스레드 이름이 main, ForkJoinPool.commonPool-worker-…처럼 섞여 찍히고 순서가 실행마다 바뀝니다.
핵심 정리
람다는 추상 메서드가 하나뿐인 인터페이스(함수형 인터페이스)의 구현을 (매개변수) -> 몸통으로 짧게 쓴 것.
Stream = 생성 → 중간 연산(filter·map·sorted, 지연 실행) → 최종 연산(forEach·collect·count). 원본은 그대로, Stream은 1회용.
값을 그대로 넘기기만 하는 람다는 클래스::메서드(메서드 참조)로 줄일 수 있다.
병렬 스트림은 "항상 빠름"이 아니다. 데이터가 적으면 느릴 수 있고 순서도 섞인다.
확인 문제D1_ILambda에 추상 메서드 int sub(int a, int b);를 하나 더 추가하면 lam2, lam3 줄은 어떻게 될까요?
컴파일 에러가 납니다. 추상 메서드가 둘이면 람다가 어느 쪽을 구현하는지 알 수 없어서, 람다는 추상 메서드가 하나인 인터페이스에만 쓸 수 있어요. 익명 클래스 방식(lam)은 두 메서드를 모두 구현하면 됩니다.
list.stream().filter(s -> s.contains("김")).sorted();처럼 최종 연산 없이 끝내면 무엇이 출력되나요?
아무것도 출력되지 않고, 걸러내기·정렬도 실제로 일어나지 않습니다. 중간 연산은 최종 연산이 불릴 때까지 실행을 미루기(지연 실행) 때문이에요.
.map(s -> s.length())를 메서드 참조로 바꾸면?
.map(String::length)입니다. 수업 코드에서도 람다 버전은 주석으로 두고 이 형태를 썼어요.
다음 장으로
지금까지 데이터는 모두 프로그램 안(메모리)에 있었어요. 18장에서는 파일·키보드처럼 프로그램 바깥과 데이터를 주고받는 입출력(IO) 스트림을 배웁니다. 이름에 똑같이 "스트림"이 붙지만 이 장의 Stream과는 다른 물건이에요.
바이트 스트림(InputStream·OutputStream)과 문자 스트림(Reader·Writer)을 구분해 고를 수 있다.
스트림을 겹쳐 끼워(노드 + 필터) 파일에 한 줄씩 쓰고, try-with-resources로 닫을 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
프로그램을 끄면 변수에 있던 값은 사라집니다. 남기려면 파일에 써야 하고, 사용자에게 받으려면 키보드에서 읽어야 하죠. 이런 바깥과의 통로가 IO 스트림이에요. 바깥 세상은 "파일이 없다" 같은 사고가 잦아서 IO 메서드는 거의 다 IOException(Checked)을 던집니다. 그래서 16장 예외 처리와 try-with-resources가 여기서 바로 쓰여요. 그리고 20장 네트워크는 소켓 위에 이 장의 스트림을 그대로 끼워서 대화합니다.
개념
① IO 스트림 — 한 방향으로만 흐르는 파이프
IO 스트림은 프로그램과 바깥(파일·키보드·모니터·네트워크) 사이에 놓인 수도관이에요. 데이터는 한쪽 방향으로, 들어간 순서대로(FIFO) 흐릅니다. 그래서 읽기용(입력)과 쓰기용(출력)이 따로 있어요. read()는 더 읽을 게 없으면 -1을 돌려주므로 while ((i = in.read()) != -1)로 끝까지 읽습니다.
17장 Stream API와의 차이 — IO 스트림(java.io)은 프로그램 바깥과 바이트·문자를 주고받는 통로이고, Stream API(java.util.stream)는 이미 메모리에 있는 컬렉션 데이터를 가공하는 파이프라인입니다. 둘 다 "흐름"이라 이름만 같아요.
📎 교안 p.155~158 「IO(Input & Output)」
② 바이트 스트림 vs 문자 스트림
InputStream·OutputStream은 1바이트씩 다루는 바이트 스트림이라 이미지·동영상 같은 모든 파일을 그대로 복사할 수 있어요. Reader·Writer는 문자(char) 단위로 다루는 문자 스트림이라 글자를 주고받을 때 편합니다. 둘 사이의 다리가 InputStreamReader·OutputStreamWriter이고, 여기에 "utf-8", "MS949"처럼 문자 인코딩을 지정합니다. 윈도우 콘솔 키보드 입력은 MS949라서 수업 코드는 new InputStreamReader(System.in, "MS949")로 읽어요.
노드 스트림(FileInputStream, FileOutputStream 등)은 파일 같은 실제 대상에 직접 붙는 관이고, 필터(보조) 스트림은 다른 스트림을 감싸 기능을 더하는 장치예요. BufferedWriter는 글자를 모아 두었다가 한 번에 쓰고(버퍼), DataOutputStream은 int·String 같은 값을 이진 데이터로 씁니다. 버퍼에 모인 데이터는 flush()나 close() 때 실제로 나가요. 다 쓴 스트림은 반드시 닫아야 하는데, Java 7+에서는 try ( … ) 괄호에 선언하면 선언의 역순으로 자동으로 닫힙니다.
📎 교안 p.155 「노드와 필터 구분」, p.163 「반드시 close() 하기」
⚠️ 교안 표현 바로잡기
교안 p.165는 Reader의 read()가 「데이터를 2byte씩 읽는다」고 하지만, 정확히는 문자 하나(char, 자바 내부에서 2바이트)를 돌려준다는 뜻입니다. 파일에서 실제로 몇 바이트를 읽는지는 인코딩에 달려 있어요. 예를 들어 UTF-8 파일에서 영문자는 1바이트, 한글 한 글자는 3바이트입니다.
교안 p.163은 close()를 finally에 쓰라고 하지만, 수업 코드처럼 try-with-resources를 쓰면 finally 없이 자동으로 닫히므로 이쪽이 더 안전하고 짧습니다.
예제 — 수업 코드로 확인하기
예제 1. 파일 복사 — 1바이트씩, 그리고 10바이트씩
이 코드는 바이트 스트림으로 읽고(-1까지) 쓰는 기본 반복문과, 배열로 여러 바이트를 한 번에 읽을 때는 "읽은 개수만큼만" 써야 한다는 것을 보여 주려고 만든 실습입니다.
JAVAD3_IOTest.java — test01() 1바이트씩 복사
// try with resource 문법 사용으로 finally 생략가능
try (
InputStream in = new FileInputStream("C:\\Antigravity26_06\\…\\temp\\test.txt"); // 경로 일부 생략
OutputStream out = new FileOutputStream("C:\\Antigravity26_06\\…\\temp\\test2.txt");) {
int i = 0;
while ((i = in.read()) != -1) { // 읽을게 없으면 -1을 리턴
System.out.println(i);
out.write(i); // 파일출력(byte단위로)
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
// …(생략: catch (Exception e))
JAVAD3_IOTest.java — test03() 이미지 10바이트씩 복사
try (
InputStream in = new FileInputStream("C:\\Antigravity26_06\\…\\temp\\images.jpg"); // 경로 일부 생략
OutputStream out = new FileOutputStream("C:\\Antigravity26_06\\…\\temp\\copy.jpg");
) { // 10바이트 단위로 읽기
byte[] b = new byte[10];
int i = 0;
while ((i = in.read(b)) != -1) { // i에 저장되는 값은 읽은 개수
// out.write(b); //그전에 읽었던 배열의 데이터가 남아있을수있음
out.write(b, 0, i); // 읽은 개수만큼만 출력 -> 안정적(권장)
}
} catch (IOException e) {
e.printStackTrace();
}
// …(생략: FileNotFoundException·Exception catch)
👀 관찰 포인트 — test01은 글자가 아니라 0~255 사이의 숫자를 한 줄씩 찍습니다. 바이트 스트림은 글자를 모르고 바이트만 옮기기 때문이에요(그래도 test2.txt는 원본과 똑같이 복사됩니다). test03은 jpg 같은 이진 파일도 같은 방법으로 복사됨을 보여 줘요. 파일 크기가 10의 배수가 아니면 마지막에는 10바이트보다 적게 읽히는데, 그때 write(b)로 배열 전체를 쓰면 지난번 찌꺼기까지 붙습니다. 경로는 강사 PC 기준이니 자기 PC 경로로 바꿔서 실행하세요.
예제 2. 다이어리 — 키보드로 한 줄씩 받아 파일 끝에 이어 쓰기
이 과제 코드는 스트림을 겹쳐 끼우는 방법(입력: 키보드 → 문자 → 한 줄 단위, 출력: 파일 → 문자(UTF-8) → 버퍼)과 이어쓰기 모드를 보여 주려고 만든 실습입니다.
JAVAD3_IOTest.java — test05() 다이어리
try (
// 키보드 입력: InputStreamReader -> BufferedReader (한 줄씩 읽기용)
InputStreamReader in = new InputStreamReader(System.in, "MS949");
BufferedReader br = new BufferedReader(in);
// 파일 출력: FileOutputStream(..., true) -> OutputStreamWriter -> BufferedWriter
FileOutputStream out = new FileOutputStream(
"C:\\Antigravity26_06\\…\\temp\\test5.txt", // 경로 일부 생략
true); // true: 기존 내용 뒤에 이어쓰기
OutputStreamWriter ow = new OutputStreamWriter(out, "utf-8");
BufferedWriter bw = new BufferedWriter(ow);) {
System.out.println("입력시작. (exit 입력 시 종료)");
String msg = "";
while ((msg = br.readLine()) != null) { // 한 줄씩 입력받기
if (msg.equals("exit")) {
System.out.println("종료합니다.");
break;
}
bw.write(msg);
bw.newLine();
bw.flush(); // 버퍼 비우기(파일에 즉시 기록)
}
} catch (IOException e) {
e.printStackTrace();
}
👀 관찰 포인트 — 저장소의 temp/test5.txt에는 실제로 입력한 세 줄("안녕하세요", "테스트 입력", "입니다.")이 남아 있습니다. 다시 실행해도 지워지지 않고 뒤에 이어 붙는 것은 FileOutputStream의 두 번째 인자 true 덕분이에요. 키보드는 MS949로 읽고 파일은 UTF-8로 쓰는 것처럼, 읽는 쪽과 쓰는 쪽 인코딩을 따로 정할 수 있다는 점도 보세요.
핵심 정리
IO 스트림(java.io) = 바깥과 데이터를 주고받는 한 방향 통로. Stream API(java.util.stream)와는 다른 것.
바이트 = InputStream/OutputStream(모든 파일), 문자 = Reader/Writer(글자). 다리는 InputStreamReader/OutputStreamWriter + 인코딩.
read()는 끝에서 -1. 배열로 읽으면 write(b, 0, 읽은개수).
노드 스트림에 필터(Buffered…, Data…)를 겹쳐 끼운다. 버퍼는 flush()/close() 때 실제로 기록된다.
스트림은 try-with-resources로 자동 close.
확인 문제17장의 list.stream()과 이 장의 FileInputStream은 무엇이 다른가요?
list.stream()은 메모리에 있는 컬렉션 원소를 걸러내고 바꾸는 Stream API(java.util.stream)이고, FileInputStream은 파일에서 바이트를 읽어 오는 IO 스트림(java.io)입니다. 이름만 같고 용도도 패키지도 다릅니다.
test05에서 bw.flush() 줄을 지우면 어떤 차이가 생길까요?
입력한 줄이 버퍼에 쌓여 있다가 "exit"로 정상 종료할 때 try-with-resources가 close()하면서 한꺼번에 기록됩니다. 하지만 도중에 프로그램을 강제로 끄면 버퍼에 남은 내용은 파일에 쓰이지 않을 수 있어요. 그래서 한 줄마다 flush한 것입니다.
jpg 파일을 복사할 때 FileReader/FileWriter를 쓰면 안 되는 이유는?
문자 스트림은 바이트를 인코딩 규칙에 따라 글자로 해석하는데, 이미지 바이트는 글자가 아니라서 해석·변환 과정에서 값이 바뀌어 파일이 깨질 수 있습니다. 이진 파일은 바이트 스트림으로 복사합니다.
다음 장으로
다이어리는 readLine()에서 입력을 기다리는 동안 아무 일도 못 합니다. 채팅 프로그램처럼 "입력을 기다리면서 동시에 남의 메시지도 받아야" 하는 프로그램을 만들려면 일꾼이 둘 이상 필요해요. 19장에서 그 일꾼, 스레드를 배웁니다.
스레드를 두 가지 방법(Runnable 구현, Thread 상속)으로 만들고 start()로 실행할 수 있다.
여러 스레드가 한 객체를 함께 쓸 때 synchronized가 왜 필요한지 설명할 수 있다.
wait()·notifyAll()로 생산자-소비자를 만들 때 while (조건) wait();로 써야 하는 이유를 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
지금까지의 프로그램은 일꾼이 한 명(main 스레드)이라 한 줄씩 차례로만 실행됐어요. 18장 다이어리처럼 키보드 입력을 기다리는 동안엔 다른 일을 전혀 못 하죠. 20장의 서버는 여러 손님을 동시에 받아야 하고, 채팅 클라이언트는 입력을 기다리면서 남의 메시지도 받아야 합니다. 그래서 네트워크 바로 앞에서 스레드를 배워요. 15장에서 "ArrayList는 스레드 안전하지 않다"고 한 말의 뜻도 이 장에서 풀립니다.
개념
① 스레드 — 한 프로그램 안의 여러 일꾼
프로세스가 식당 하나라면, 스레드는 그 안에서 일하는 직원이에요. 직원이 여럿이면 주문받기와 요리를 동시에 할 수 있죠. 스레드가 할 일은 run() 안에 쓰고, 실행은 반드시 start()로 합니다. start()는 새 스레드를 만들어 "실행 대기" 줄에 세우고, 언제 실제로 돌지는 JVM과 운영체제(스케줄러)가 정해요. 그래서 실행할 때마다 출력 순서가 달라질 수 있습니다. run()을 직접 부르면 새 스레드 없이 그냥 메서드 호출이 됩니다.
📎 교안 p.168 「스레드 기본 개념」, p.172~173 「스레드 제어·메서드 종류」
② 만드는 법 두 가지 — Runnable 구현 vs Thread 상속
implements Runnable로 할 일만 정의하고 new Thread(할일객체)에 넘기는 방법과, extends Thread로 스레드 자체를 만드는 방법이 있어요. 자바는 클래스를 하나만 상속할 수 있으니 이미 다른 클래스를 상속 중이면 Runnable을 씁니다. 작업(Runnable)과 실행 수단(Thread)이 분리되어 재사용하기도 좋아요. setPriority(1~10)로 우선순위를 줄 수 있지만 운영체제에 주는 힌트일 뿐이라 순서가 보장되지는 않습니다.
📎 교안 p.169~170 「스레드 생성 방법」
③ 동기화 — synchronized는 "한 명씩 들어가는 화장실 열쇠"
두 스레드가 같은 객체를 동시에 고치면 서로 덮어써서 값이 틀어질 수 있어요. synchronized (객체) { … } 블록이나 synchronized 메서드는 그 객체의 열쇠(락)를 가진 스레드 하나만 들어가게 합니다. 다른 스레드는 열쇠가 반납될 때까지 문 앞에서 기다려요. StringBuffer는 메서드에 이미 동기화가 걸려 있어 스레드 안전하고, StringBuilder는 동기화가 없어 빠르지만 여러 스레드가 함께 쓰면 안전하지 않습니다.
📎 교안 p.171 「스레드 동기화」
④ wait · notifyAll — 조건이 맞을 때까지 쉬었다가 깨우기
빵 접시(최대 10개)를 생각해 보세요. 먹는 스레드는 빵이 없으면 wait()로 열쇠를 반납하고 잠들고, 만드는 스레드는 빵을 만든 뒤 notifyAll()로 잠든 스레드들을 깨웁니다. 깨어난 스레드는 열쇠를 다시 얻어야 이어서 실행해요. 이 메서드들은 synchronized 안에서만 부를 수 있습니다(밖에서 부르면 IllegalMonitorStateException). 그리고 깨어났다고 조건이 맞다는 보장이 없으므로(다른 스레드가 먼저 먹었거나, 이유 없이 깨는 경우도 있음) while (조건) wait();로 다시 확인해야 합니다.
교안 p.171은 synchronized를 「현재 스레드가 종료되기 전까지 다른 쓰레드는 실행할 수 없다」고 설명하지만, 다른 스레드는 계속 실행됩니다. 막히는 건 같은 객체의 락이 필요한 synchronized 영역에 들어가는 것뿐이고, 락은 스레드가 끝날 때가 아니라 블록(메서드)을 빠져나올 때 반납됩니다.
수업 코드 D3_CakePlate는 if (breadCount < 1) wait();처럼 if로 검사합니다. 먹는 스레드와 만드는 스레드가 하나씩일 때는 대개 맞게 돌지만, 정석은 while (breadCount < 1) { wait(); }, while (breadCount >= 10) { wait(); }입니다. 먹는 스레드가 둘이 되면 if 버전은 둘이 한꺼번에 깨어나 빵을 -1개로 만들 수 있어요. 또 주석의 「10개 모두 소진 → 10개 모두 만듦」처럼 딱딱 번갈아 진행된다는 보장은 없습니다. wait/notify가 보장하는 것은 빵 개수가 0~10 범위를 벗어나지 않는다는 것뿐이고, 언제 누가 실행될지는 스케줄러가 정해요.
예제 — 수업 코드로 확인하기
예제 1. "안"과 "녕"을 동시에 찍기, 그리고 스레드 만드는 두 방법
이 코드는 for문 두 개는 차례로 돌지만, 스레드 두 개는 동시에 돈다는 것과 Runnable 구현 · Thread 상속 두 방법을 보여 주려고 만든 실습입니다.
JAVAD4_ThreadTest.java (16일차) — 익명 클래스로 스레드 두 개
// 작업단위1
Thread t1 = new Thread() { // 익명클래스 방식으로...
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.print("안");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
// …(생략: t2도 같은 모양으로 "녕"을 출력)
// 주의 : .run()을 직접 호출하면 쓰레드가 생성되지 않는다!
t1.start();
t2.start();
JAVAD1_RunableTest · D1_ThreadInheriTest · D1_ThreadMain (18일차) — 두 가지 생성법
public class D1_RunableTest implements Runnable { // 방법 1: 할 일만 정의
@Override
public void run() { /* …(생략: "나는 Runnable을 구현한 스레드다" 50번, 0.5초 간격) */ }
}
public class D1_ThreadInheriTest extends Thread { // 방법 2: 스레드 자체를 상속
@Override
public void run() { /* …(생략: "나는 Thread를 상속받은 스레드다" 50번) */ }
}
// D1_ThreadMain.main()
Runnable runObj = new D1_RunableTest();
Thread tr1 = new Thread(runObj); // Runnable은 Thread에 넘겨서 실행
tr1.start();
tr1.setPriority(Thread.MAX_PRIORITY); // 우선순위범위: 1~10까지
Thread tr2 = new D1_ThreadInheriTest();
tr2.start();
tr2.setPriority(Thread.MIN_PRIORITY);
👀 관찰 포인트 — 앞부분 for문 두 개는 안안안안안녕녕녕녕녕으로 찍히고, 스레드 버전은 안녕안녕…처럼 섞여 찍힙니다. 단, 0.5초 sleep 덕분에 섞여 보이는 것일 뿐 "안" 다음에 꼭 "녕"이 온다는 보장은 없어요(녕안안녕…도 가능). 18일차 코드도 우선순위를 10과 1로 줬지만 출력이 확실히 한쪽으로 몰리지는 않습니다. 우선순위는 힌트일 뿐이니까요.
예제 2. 함께 쓰는 객체 — synchronized와 StringBuffer
이 코드는 두 스레드가 한 객체를 함께 쓰는 상황에서 synchronized 블록이 한 스레드씩만 들여보낸다는 것, 그리고 동기화된 StringBuffer는 함께 써도 글자가 사라지지 않는다는 것을 보여 주려고 만든 실습입니다.
JAVAD2_ThreadSync.java — synchronized 블록
public static StringBuilder sb = new StringBuilder();
public static StringBuffer sf = new StringBuffer();
public void sbTest(String s) {
for (int i = 0; i < 1000; i++) {
sf.append(s);
}
// …(생략: 2초 sleep)
System.out.println(sf.length());
}
// main 안
ShareObject so = new D2_ThreadSync().new ShareObject();
Thread trA = new Thread() {
@Override
public void run() {
synchronized (so) { // so의 열쇠를 가진 스레드만 들어온다
so.print("공"); // print(): 0.5초 간격으로 10번 출력
}
}
};
// …(생략: trB는 so.print("유"). 수업에서는 trA·trB 실행 줄을 주석 처리하고,
// 아래처럼 tr1·tr2가 각각 sbTest("A")·sbTest("B")를 부르게 해서 실행)
👀 관찰 포인트 — trA·trB 실행 줄의 주석을 풀면 "공"이 10번 다 나온 뒤에 "유"가 10번 나옵니다(어느 쪽이 먼저일지는 정해져 있지 않아요). synchronized (so)를 지우면 공과 유가 섞여요. sbTest는 두 스레드가 같은 static sf에 1000번씩 붙이므로 주석의 1000이 아니라 대개 두 줄 모두 2000을 찍습니다(둘 다 2초를 기다린 뒤 출력하니까요). sf를 동기화가 없는 sb(StringBuilder)로 바꾸면 2000보다 작게 나오거나 예외가 날 수 있어요.
예제 3. 생산자-소비자 — 케이크 접시
이 코드는 교안 p.174 그림 그대로, 만드는 스레드와 먹는 스레드가 접시 하나(공유 객체)를 두고 wait/notifyAll로 서로 기다리고 깨우는 모습을 보여 주려고 만든 실습입니다.
JAVAD3_CakePlate.java — 공유 객체 (원본, if 사용)
public class D3_CakePlate {
private int breadCount = 0;
public synchronized void eatBread() {
if (breadCount < 1) { // ⚠️ 정석은 while (breadCount < 1)
System.out.println("빵이 모자라서 기다려야 함");
try {
wait();// 스레드를 일시정지
} catch (InterruptedException e) {
e.printStackTrace();
}
}
breadCount--;
System.out.println("빵을 1개 먹음. 총 " + breadCount + "개 남음");
notifyAll();// 모든 스레드를 실행대기로 설정
}
public synchronized void makeBread() {
if (breadCount >= 10) { // ⚠️ 정석은 while (breadCount >= 10)
System.out.println("빵이 남아요!");
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
breadCount++;
System.out.println("빵을 1개 더 만듦. 총 " + breadCount + "개");
notifyAll();
}
}
JAVAD3_CakeMain.java — 두 스레드가 같은 접시를 공유
D3_CakePlate cake = new D3_CakePlate(); // 스레드들이 공유할 객체
D3_CakeEater eater = new D3_CakeEater(cake); // run(): eatBread() 30번
D3_CakeMaker maker = new D3_CakeMaker(cake); // run(): makeBread() 30번
Thread t1 = new Thread(eater);
Thread t2 = new Thread(maker);
t2.setPriority(10);
t1.setPriority(1);
t1.start();
t2.start();
👀 관찰 포인트 — 실행할 때마다 출력 모양이 달라집니다. 어떤 때는 하나 만들고 하나 먹기를 반복하고, 어떤 때는 몇 개 몰아서 만들어요. 그래도 "총 ○개"는 절대 0 미만, 10 초과가 되지 않고, 30개를 만들고 30개를 먹으니 마지막엔 0개가 남습니다. 이것이 wait/notifyAll이 보장하는 전부예요.
핵심 정리
할 일은 run()에, 실행은 start(). 실행 순서는 스케줄러가 정하므로 매번 다를 수 있다.
생성법: implements Runnable + new Thread(obj) (다른 클래스 상속 중일 때) / extends Thread.
synchronized = 같은 객체의 락을 가진 스레드 하나만 들어간다. 다른 스레드가 멈추는 게 아니라 그 영역 앞에서만 기다린다.
wait()·notifyAll()은 synchronized 안에서, 대기는 while (조건) wait();. 보장되는 건 "범위 유지"이지 "번갈아 실행"이 아니다.
확인 문제D4_ThreadTest에서 t1.start(); t2.start();를 t1.run(); t2.run();으로 바꾸면 출력이 어떻게 될까요?
새 스레드가 생기지 않고 main 스레드가 run()을 차례로 부르므로 안안안안안녕녕녕녕녕이 0.5초 간격으로 순서대로 찍힙니다. 섞이지 않아요.
먹는 스레드를 두 개 만들어 같은 접시를 쓰게 하면, if 버전의 eatBread()에서 어떤 문제가 생길 수 있나요?
빵이 0개라 두 먹는 스레드가 모두 wait() 중일 때, 빵이 1개 만들어지고 notifyAll()이 둘 다 깨웁니다. 첫 번째가 먹어 0개가 된 뒤 두 번째는 다시 검사하지 않고(if라서) 바로 breadCount--를 해서 -1개가 될 수 있어요. while로 바꾸면 깨어난 뒤 다시 검사해 또 기다립니다.
synchronized가 없는 일반 메서드 안에서 wait()를 부르면?
실행 중에 IllegalMonitorStateException이 납니다. wait는 "지금 가진 락을 반납하고 잠든다"는 뜻이라, 락을 가진 synchronized 영역 안에서만 부를 수 있어요.
다음 장으로
이제 재료가 다 모였습니다. 18장의 IO 스트림으로 데이터를 주고받고, 이 장의 스레드로 여러 손님을 동시에 상대하면 그게 바로 서버예요. 20장에서 소켓으로 두 프로그램을 연결하고, 마지막엔 여러 명이 대화하는 채팅 프로그램까지 만듭니다.
ServerSocket·Socket으로 서버와 클라이언트를 연결하고, 소켓 위에 18장의 IO 스트림을 끼워 메시지를 주고받을 수 있다.
손님마다 스레드를 붙이는 멀티스레드 서버와 채팅 서버의 구조(명단 + 브로드캐스트)를 설명할 수 있다.
UDP에서 DatagramPacket을 다룰 때 getLength()가 왜 중요한지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
3부에서 배운 것이 여기서 한꺼번에 모입니다. 소켓은 상대 컴퓨터로 이어진 IO 스트림(18장)이고, 연결이 끊기는 사고는 IOException(16장)으로 처리하며, 여러 손님은 스레드(19장)로 동시에 받고, 손님 명단은 Set(15장)에 담고, 수신 스레드는 람다(17장)로 짧게 씁니다. 그리고 나중에 배울 웹(브라우저 ↔ 톰캣 서버)도 결국 이 장의 TCP 소켓 위에서 돌아가요.
개념
① IP · 포트 · TCP · UDP
IP 주소는 아파트 동(어느 컴퓨터인지), 포트 번호(0~65535)는 호수(그 컴퓨터의 어느 프로그램인지)예요. 0~1023은 HTTP 80, HTTPS 443처럼 이미 정해진 번호라 수업에서는 9595, 5000, 12345 같은 번호를 썼습니다. localhost(127.0.0.1)는 "내 컴퓨터 자신"이에요. TCP는 전화처럼 먼저 연결을 맺고 순서대로 빠짐없이 전달해 주고(일반적인 웹 통신이 이 방식), UDP는 문자 메시지처럼 연결 없이 덩어리(패킷)를 보내기만 해서 빠르지만 도착·순서를 보장하지 않습니다.
서버는 new ServerSocket(포트)로 가게 문을 열고 accept()에서 손님이 올 때까지 멈춰 기다립니다(블록). 클라이언트가 new Socket("localhost", 포트)로 접속하면 accept()가 그 손님 전용 Socket을 돌려줘요. 그다음은 18장과 똑같습니다. socket.getInputStream()을 BufferedReader로 감싸 readLine()으로 읽고, socket.getOutputStream()을 PrintWriter(…, true)로 감싸 println()으로 보내요. true는 println마다 자동 flush하라는 뜻이고, readLine()은 줄바꿈이 올 때까지 기다리므로 보낼 때는 꼭 println을 씁니다.
스레드가 하나뿐인 서버는 첫 손님과 대화하는 while문 안에 갇혀서, 그 손님이 나갈 때까지 accept()로 돌아가지 못해요. 그래서 main 스레드는 문 앞에서 accept()만 하고, 손님이 오면 전담 스레드를 만들어 대화를 맡깁니다. 채팅 서버는 여기에 "모든 손님의 출력 스트림 명단"(Set<PrintWriter>)을 더해, 한 사람의 메시지를 명단 전원에게 println합니다(브로드캐스트). 여러 스레드가 명단을 동시에 고치고 읽으니 synchronized가 필요해요. 클라이언트도 "키보드 입력 보내기"와 "서버 메시지 받기"를 동시에 해야 하니 수신 전용 스레드를 따로 둡니다.
📎 교안 p.195~196 「멀티스레드 서버」, p.197 「실습(채팅프로그램구현)」
④ UDP — 패킷 단위로 보내고 받기
UDP는 연결이 없어서 accept()도 스트림도 없어요. DatagramSocket이 우체통, DatagramPacket이 편지 봉투입니다. 받을 때는 빈 바이트 배열로 봉투를 만들어 receive(packet), 보낼 때는 데이터 + 길이 + 상대 주소 + 포트를 봉투에 적어 send(packet)해요. 받은 봉투에서 getAddress()·getPort()로 보낸 사람을 알 수 있습니다. 중요한 점은 배열 크기(512)와 실제로 받은 바이트 수가 다르다는 것이에요. 실제 길이는 packet.getLength()로 얻습니다(18장의 write(b, 0, i)와 같은 이야기예요).
UDP 에코 서버의 응답 길이 — 교안 p.193과 수업 코드 D2_UDPServer 모두 되돌려 보낼 때 new DatagramPacket(buffer, buffer.length, address, port)를 씁니다. 이러면 받은 글자 수와 상관없이 512바이트 전체가 돌아가서, 클라이언트 화면에 메시지 뒤로 빈 바이트와 이전 메시지의 찌꺼기가 붙어 나와요("hello world" 다음에 "hi"를 보내면 "hillo world…"처럼). new DatagramPacket(buffer, packet.getLength(), address, port)로 받은 길이만큼만 보내야 합니다(오른쪽의 packet은 대입 전이라 아직 받은 패킷이에요).
D2_UDPClient의 hostname = "192.168.22.2"는 수업 때 강사 PC 주소입니다. 혼자 실습할 때는 "localhost"로 바꾸세요. D4_ChatServer2는 InetAddress.getLoopbackAddress()에 묶여 있어 같은 PC에서만 접속할 수 있습니다.
예제 — 수업 코드로 확인하기
예제 1. 한 명만 받는 서버 → 여러 명을 받는 서버
18일차 서버는 TCP 연결과 소켓 위 IO 스트림을, 19일차 서버는 같은 일을 손님마다 스레드로 나눠 동시에 처리하는 방법을 보여 주려고 만든 실습입니다. 클라이언트는 둘 다 D4_TCPClient(9595번 포트)를 씁니다.
JAVAD4_TCPServer.java (18일차) — 싱글 스레드 서버
serverSocket = new ServerSocket(9595);
System.out.println("서버가 클라이언트의 접속을 기다립니다...");
while (true) {
clientSocket = serverSocket.accept(); // 손님이 올 때까지 멈춤
System.out.println("클라이언트 연결됨:" + clientSocket.getInetAddress().getHostName());
out = new PrintWriter(clientSocket.getOutputStream(), true);
in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
String inputLine;
while ((inputLine = in.readLine()) != null) { // 이 손님이 나갈 때까지 여기 갇힘
System.out.println("클라이언트 메시지:" + inputLine);
out.println("메시지를 잘 전달받았습니다.");
}
}
// …(생략: catch, finally에서 out·in·clientSocket·serverSocket 차례로 close)
JAVAD1_MultiTcpServer.java (19일차) — 손님마다 스레드
try (ServerSocket serverSocket = new ServerSocket(9595);) {
System.out.println("server is running now...");
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("클라이언트 연결됨:" + clientSocket.getInetAddress().getHostName());
new ServerThread(clientSocket).start(); // 대화는 전담 스레드에게, main은 다시 accept로
}
} catch (Exception e) {
e.printStackTrace();
}
static class ServerThread extends Thread {
Socket clientSocket = null;
public ServerThread(Socket clientSocket) {
this.clientSocket = clientSocket;
}
@Override
public void run() {
try (
Socket autoCloseSocket = clientSocket; // close() 자동 처리용으로 등록
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("클라이언트로부터 전달받은 메시지:" + inputLine);
out.println("보낸 메시지:" + inputLine);
}
} catch (IOException e) {
}
}
}
👀 관찰 포인트 — 서버를 먼저 실행하고 클라이언트 창을 두 개 띄워 보세요. 18일차 서버에서는 두 번째 클라이언트가 메시지를 보내도 첫 번째 클라이언트가 끊을 때까지 답이 오지 않습니다(안쪽 while에 갇혀 있으니까요). 19일차 서버에서는 둘 다 바로 답을 받아요. 바뀐 것은 안쪽 while을 run()으로 옮긴 것뿐입니다.
예제 2. UDP 에코 서버 — 연결 없이 패킷 주고받기
이 코드는 연결(accept) 없이 패킷을 받고, 보낸 사람 주소를 패킷에서 꺼내 되돌려 보내는 UDP 방식을 보여 주려고 만든 실습입니다(교안 p.193 예제와 거의 같아요).
JAVAD2_UDPServer.java — 원본 (응답 길이 버그 포함)
try (DatagramSocket socket = new DatagramSocket(5000);) {
byte[] buffer = new byte[512];
// 수신용 패킷 생성
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
System.out.println("server is listening on port 5000");
while (true) {
socket.receive(packet); // 패킷이 올 때까지 멈춤
String received = new String(packet.getData(), 0, packet.getLength()); // ✔ 받은 길이만큼
System.out.println("받은 메시지:" + received);
InetAddress address = packet.getAddress(); // 보낸 사람 주소
int port = packet.getPort(); // 보낸 사람 포트
System.out.println("address:" + address + ",port:" + port);
packet = new DatagramPacket(buffer, buffer.length, address, port); // ⚠️ buffer.length(512) → packet.getLength()로 고쳐야 함
socket.send(packet);// 클라이언트로 보냄
}
}
// …(생략: catch)
👀 관찰 포인트 — 서버의 "받은 메시지"는 getLength()를 썼으니 정확합니다. 하지만 클라이언트(D2_UDPClient)는 매번 512바이트를 받으므로, 짧은 메시지 뒤에 빈 문자나 앞 메시지의 남은 글자가 붙어 보여요. 표시한 줄을 packet.getLength()로 고치면 보낸 그대로 돌아옵니다.
예제 3. 채팅 — 명단에 등록하고 모두에게 방송하기
이 코드는 멀티스레드 서버 + 공유 명단(Set) + 동기화 + 브로드캐스트, 그리고 클라이언트의 송신(main)·수신(별도 스레드) 분리를 한 프로그램에 모아 보여 주려고 만든 실습입니다.
JAVAD3_ChatServer.java — 명단과 브로드캐스트
// 여러 스레드가 동시에 접속자 명단에 추가/삭제하므로 동기화(synchronizedSet) 필수!
private static Set<PrintWriter> clientWriters = Collections.synchronizedSet(new HashSet<>());
public static void broadcast(String message) {
// 여러 스레드가 동시에 명단을 읽는 도중 오류가 나지 않도록 동기화 블록 설정
synchronized (clientWriters) {
for (PrintWriter writer : clientWriters) {
writer.println(message);// 각 클라이언트의 스피커로 메시지 전송
}
}
}
// ClientHandler.run() — 손님 1명 전담 스레드
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
out = new PrintWriter(socket.getOutputStream(), true);
clientWriters.add(out); // 2.전체 명단에 내 마이크 등록
nickname = in.readLine(); // 3.맨 처음 한 줄은 닉네임
broadcast(nickname + "님이 입장하셨습니다.");
String message;
while ((message = in.readLine()) != null) {
broadcast("[" + nickname + "]" + message);
}
// …(생략: catch, finally에서 clientWriters.remove(out), 퇴장 방송, socket.close())
JAVAD3_ChatClient.java — 수신 스레드를 람다로
// 6. 서버에서 오는 메시지를 실시간으로 수신하여 출력하는 별도 스레드 시작
Thread receiveThread = new Thread(() -> {
try {
String serverMessage;
while ((serverMessage = in.readLine()) != null) {
System.out.println(serverMessage);
}
} catch (IOException e) {
System.out.println("서버와의 연결이 끊어졌습니다.");
}
});
receiveThread.start();
// 7. 메인 스레드는 키보드 입력을 계속 읽어서 서버로 전송 (송신 전용)
String userInput;
while ((userInput = keyboard.readLine()) != null) {
out.println(userInput);
}
👀 관찰 포인트 — new Thread(() -> { … })는 Runnable(추상 메서드 run() 하나)을 17장의 람다로 구현한 것이에요. 클라이언트 창 세 개로 접속하면 한 명이 보낸 글이 세 창 모두에 [닉네임]메시지로 뜹니다. 서버 쪽 PrintWriter는 문자셋을 적지 않았지만 JDK 18부터 기본 문자셋이 UTF-8이라 이 프로젝트의 JDK 21에서는 클라이언트의 UTF-8과 맞아요. D4_ChatServer2처럼 "UTF-8"을 직접 적어 두는 게 더 안전합니다.
핵심 정리
IP = 어느 컴퓨터, 포트 = 그 안의 어느 프로그램. TCP = 연결·신뢰·순서 보장, UDP = 비연결·빠름·보장 없음.
멀티스레드 서버: main은 accept만, 대화는 손님별 스레드. 채팅은 여기에 출력 스트림 명단 + synchronized 브로드캐스트.
UDP: DatagramSocket + DatagramPacket. 받은 데이터를 읽을 때도, 되돌려 보낼 때도 packet.getLength().
확인 문제new PrintWriter(socket.getOutputStream(), true)에서 true를 빼면 어떤 증상이 생길까요?
자동 flush가 꺼져서 println한 내용이 버퍼에 머물고 상대에게 바로 가지 않습니다. 상대는 readLine()에서 계속 기다리고, 이쪽은 답을 기다리니 둘 다 멈춘 것처럼 보여요. true를 주거나 보낼 때마다 flush()를 불러야 합니다.
채팅 클라이언트에서 수신 스레드 없이 main 스레드 하나로만 처리하면 무엇이 불편할까요?
main이 keyboard.readLine()에서 내 입력을 기다리는 동안에는 서버에서 온 메시지를 읽을 수 없습니다. 내가 아무것도 치지 않으면 남의 대화가 화면에 나타나지 않아요. 그래서 받기는 별도 스레드에게 맡겼습니다.
D2_UDPServer의 응답 줄을 어떻게 고쳐야 하고, 왜 그런가요?
packet = new DatagramPacket(buffer, packet.getLength(), address, port);로 고칩니다. buffer.length는 배열 크기(항상 512)이고 실제로 받은 바이트 수는 getLength()이기 때문이에요. 512를 쓰면 빈 바이트와 이전 메시지 찌꺼기까지 같이 보내집니다.
D3_ChatServer의 broadcast()에서 synchronized (clientWriters)를 빼면 어떤 위험이 있나요?
한 스레드가 for문으로 명단을 도는 도중에 다른 스레드가 손님을 추가·삭제하면 ConcurrentModificationException이 날 수 있습니다. synchronizedSet은 add·remove 하나하나만 보호하고, for문으로 도는 동안은 직접 synchronized로 감싸야 해요.
다음 장으로
여기까지가 자바 언어 과정입니다. 지금까지 데이터는 프로그램이 꺼지면 사라지거나(변수), 파일에 한 줄씩 쌓였어요(18장). 다음 부에서는 많은 데이터를 표로 저장하고 빠르게 찾는 데이터베이스와 SQL을 배웁니다. 나중에 자바 프로그램은 이 장과 같은 방식으로 DB 서버에 네트워크로 접속해서 SQL을 보내게 돼요.
SELECT 열 FROM 표 WHERE 조건 ORDER BY 기준을 짜고, 각 절이 무슨 일을 하는지 말할 수 있다.
비교 · AND/OR · BETWEEN · IN · LIKE로 행을 거르고, NULL은 IS NULL로만 찾는다는 것을 안다.
적는 순서와 실행되는 순서가 다르다는 것을 알고, 그래서 별칭을 WHERE에서 못 쓰는 이유를 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
지금까지는 데이터를 자바 프로그램 안(변수·배열·컬렉션·파일)에 담았어요. 프로그램이 끝나면 사라지거나, 원하는 것을 찾으려면 반복문을 직접 짜야 했죠. 데이터베이스는 프로그램 밖에 있는 커다란 창고이고, SQL은 그 창고에 "무엇을 원하는지"만 말하는 언어입니다. "어떻게 찾을지"는 DB가 정해요. 이 장은 그 첫 문장인 SELECT를 다룹니다. 뒤의 모든 장(집계·조인·수정)이 이 문장 위에 올라갑니다.
개념
① 테이블 · 행 · 열 — 그리고 SELECT ~ FROM
테이블은 엑셀 시트와 같은 표입니다. 가로 한 줄이 행(사원 한 명), 세로 한 칸이 열(이름·월급 같은 항목)이에요. 수업에서는 scott(사원 EMP 14행, 부서 DEPT 4행)과 직접 만든 sqlDB(회원 usertbl 10행, 구매 buytbl 12행)를 주로 썼습니다. 먼저 USE scott;으로 어느 데이터베이스에서 일할지 고르고, SELECT 열1, 열2 FROM 표;로 원하는 열만 꺼냅니다. *는 "모든 열"입니다. SELECT 자리에서 계산(SAL*12)을 해서 없던 열을 만들 수도 있고, AS 별칭으로 결과 머리글에 이름을 붙입니다.
📎 쿼리 #1.sql 「Select ~ from 사용하기 Q1~Q9」 · 20일차
② WHERE — 조건에 맞는 행만 남기기
SELECT가 열을 고른다면 WHERE는 행을 고릅니다. 체에 거르는 것과 같아요. 조건을 쓰는 방법은 다섯 가지입니다.
비교=>=<=<>(같지 않다) · 논리AND(둘 다), OR(하나라도) · 범위BETWEEN 작은값 AND 큰값(양 끝 포함) · 목록IN (a, b, c)(= OR를 줄여 쓴 것) · 패턴LIKE(%는 0글자 이상, _는 정확히 한 글자).
그리고 NULL. NULL은 0도 빈 글자도 아닌 "값을 모름"이라서 = NULL로 비교하면 참도 거짓도 아닌 UNKNOWN이 되어 한 행도 안 나옵니다. NULL은 반드시 IS NULL / IS NOT NULL로 찾습니다. 참고로 수업 DB의 기본 정렬 규칙(collation)은 대소문자를 가리지 않아서 'salesman'으로 써도 'SALESMAN'과 일치합니다.
ORDER BY 열 DESC, 열2 ASC로 줄을 세웁니다. 앞에 쓴 것이 1순위, 같으면 2순위로 가릅니다. ORDER BY가 없으면 순서는 보장되지 않아요.
중요한 것은 DB가 문장을 처리하는 순서입니다. 우리는 SELECT부터 적지만, DB는 FROM → WHERE → (GROUP BY → HAVING) → SELECT → ORDER BY → LIMIT 순으로 돕니다. 표를 가져오고(FROM), 행을 거르고(WHERE), 그다음에야 열을 고르고 별칭을 붙여요(SELECT). 그래서 SELECT에서 만든 별칭 sal_year는 WHERE에서는 아직 없는 이름이라 쓸 수 없고, SELECT보다 뒤에 도는 ORDER BY에서는 쓸 수 있습니다. 괄호 안 두 단계는 22장에서 채웁니다.
📎 쿼리 #1.sql 「정렬하기」 · 22일차
예제 — 수업 쿼리로 확인하기
sqlDB 회원 표에서 WHERE 조건 다섯 가지 써 보기
이 쿼리들은 WHERE의 조건 연산자가 각각 어떤 행을 남기는지 보여 주려고 짠 실습입니다. 같은 usertbl 10명에게 조건만 바꿔 가며 몇 명이 남는지 세어 보세요. 주석의 결과는 sqldb생성.sql의 INSERT 데이터로 따져 본 것입니다.
SQL쿼리 #1.sql — sqlDB WHERE 실습(21일차)
SELECT *
FROM usertbl
WHERE NAME = '김경호';
SELECT userid, name
FROM usertbl
WHERE birthyear >= 1970 AND height >= 182; -- 2명: 이승기, 성시경
SELECT userid, name
FROM usertbl
WHERE birthyear >= 1970 OR height >= 182; -- 7명 (AND보다 훨씬 넓다)
SELECT NAME, height
FROM usertbl
WHERE height BETWEEN 180 AND 183; -- 이승기 182, 임재범 182
SELECT NAME, addr
FROM usertbl
WHERE addr IN ('경남','전남','경북'); -- 김범수, 김경호, 윤종신, 은지원
SELECT NAME, height
FROM usertbl
WHERE NAME LIKE '_종신'; -- 윤종신 (_ 는 딱 한 글자)
👀 관찰 포인트 — AND는 조건이 늘수록 결과가 줄고, OR는 늘어납니다(2명 → 7명). BETWEEN 180 AND 183은 height >= 180 AND height <= 183과 똑같고, IN (...)은 addr = '경남' OR addr = '전남' OR ...과 똑같아요. 둘 다 "줄여 쓰기"일 뿐입니다.
scott 사원 표 — 계산 열 · 별칭 · 날짜 범위 · 정렬
이 쿼리들은 SELECT 자리에서 새 열을 만들고, 날짜에도 범위 조건을 걸고, 결과를 줄 세우는 법을 보여 주려고 푼 문제입니다. 마지막 Q9에는 일부러 짚어 둘 함정이 하나 들어 있어요.
SQL쿼리 #1.sql — scott Q3 · WHERE Q4 · Q9 · 정렬하기
USE scott;
-- Q3) 사원 테이블에서 사원의 이름과 연봉을 출력하자.
SELECT ENAME, SAL*12 AS sal_year
FROM EMP;
-- (2. WHERE) Q4) 1980년도에서 1982년도 사이에 입사한 사원의 이름과 입사일
SELECT ENAME, HIREDATE
FROM emp
WHERE HIREDATE BETWEEN '1980-01-01' AND '1982-12-31'; -- 12명 (1987년 입사 SCOTT·ADAMS 제외)
-- Q9) "OO님이 0000-00-00에 입사를 하고 OO의 월급을 받습니다." 한 컬럼으로
SELECT CONCAT(ENAME, '님이 ', HIREDATE, '에 입사를 하고 ', SAL, '의 월급을 받습니다.') AS 사원정보
FROM EMP
ORDER BY '급여지급현황'; -- ⚠️ 아래 바로잡기 참고
-- 정렬하기
USE sqldb;
SELECT NAME, height
FROM usertbl
ORDER BY height DESC, NAME ASC; -- 성시경 186, 이승기 182, 임재범 182, 김경호 177 …
👀 관찰 포인트 — 결과 머리글이 SAL*12가 아니라 sal_year로 나옵니다(AS의 효과). 문자열을 이어 붙일 때는 +가 아니라 CONCAT()을 씁니다. 마지막 정렬에서 키가 182로 같은 이승기·임재범은 2순위인 이름 오름차순으로 순서가 정해졌어요.
⚠️ 수업 쿼리에서 조심할 곳
Q9의 ORDER BY '급여지급현황'은 정렬이 되지 않습니다. 작은따옴표로 감싸면 열 이름이 아니라 글자 상수가 되어, 모든 행이 같은 값 "급여지급현황"으로 정렬되는 셈이거든요. 에러가 안 나서 더 헷갈립니다. 정렬하려면 ORDER BY SAL DESC처럼 따옴표 없이 열 이름을 써야 합니다. 또 WHERE Q2의 ENAME LIKE 'SMITH'는 %·_가 없어서 ENAME = 'SMITH'와 똑같이 동작합니다 — 패턴이 필요 없으면 =가 뜻이 더 분명해요.
핵심 정리
SELECT는 열을, WHERE는 행을 고른다. *는 모든 열.
BETWEEN A AND B는 양 끝 포함(작은 값을 앞에), IN은 OR의 줄임, LIKE의 %=0글자 이상 · _=정확히 한 글자.
NULL은 "모름" — = NULL은 항상 0행, 반드시 IS NULL / IS NOT NULL.
실행 순서: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. 그래서 SELECT의 별칭은 WHERE에서 못 쓰고 ORDER BY에서는 쓴다.
ORDER BY가 없으면 순서는 보장되지 않는다. 따옴표로 감싼 이름은 열이 아니라 글자다.
확인 문제WHERE height BETWEEN 183 AND 180으로 바꾸면 결과는?
에러 없이 0행입니다. height >= 183 AND height <= 180으로 풀리는데, 이 둘을 동시에 만족하는 수는 없으니까요. BETWEEN은 작은 값을 앞에 씁니다.
SELECT ENAME, SAL*12 AS sal_year FROM EMP WHERE sal_year > 30000;은 왜 에러가 날까요? 어떻게 고치나요?
WHERE가 SELECT보다 먼저 실행돼서, 그 시점에는 sal_year라는 이름이 아직 없기 때문입니다(Unknown column 에러). WHERE SAL*12 > 30000처럼 식을 그대로 쓰면 됩니다. 결과는 월급이 2500보다 많은 KING·SCOTT·FORD·JONES·BLAKE 5명. ORDER BY에서는 ORDER BY sal_year를 쓸 수 있습니다.
커미션(COMM)이 없는 사원을 찾으려고 WHERE COMM = NULL이라고 썼더니 0행입니다. 왜이고, 바르게 쓰면 몇 명인가요?
= NULL은 결과가 UNKNOWN이라 어떤 행도 통과하지 못합니다. WHERE COMM IS NULL로 써야 하고, 결과는 10명입니다. TURNER는 COMM이 0이라 NULL이 아니므로 빠져요 — 0과 NULL은 다릅니다.
usertbl에서 성이 '김'인 회원만 찾는 WHERE 조건은?
WHERE NAME LIKE '김%' — 김범수, 김경호 2명. '김_'로 쓰면 두 글자 이름만 찾게 되어 0행입니다.
다음 장으로
지금까지는 행을 골라내기만 했어요. 22장에서는 고른 행들을 묶어서 숫자 하나로 요약합니다(부서별 평균 월급 같은 것). 실행 순서에서 비워 둔 GROUP BY → HAVING 자리가 거기서 채워집니다.
📅 22 · 24일차 · 9/2, 9/4💻 sql_edu_project/쿼리 #1.sql (7·8번 Group by, 윈도우 함수)📎 교안 없음 — 수업 쿼리가 교재
이 장의 목표
COUNT·SUM·AVG·MAX·MIN이 NULL을 빼고 계산한다는 것과, COUNT(*)와 COUNT(열)의 차이를 설명할 수 있다.
GROUP BY로 그룹별 요약을 만들고, 조건을 WHERE에 둘지 HAVING에 둘지 실행 순서로 판단할 수 있다.
윈도우 함수(OVER)가 GROUP BY와 달리 행을 접지 않는다는 것을 알고, 순위 함수 세 가지의 동점 처리를 구별할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
21장에서는 표에서 원하는 행을 골라내기만 했습니다. 하지만 실제로 궁금한 건 "부서별 평균 월급", "회원별 총 구매액" 같은 요약이 많아요. 자바라면 반복문을 돌며 합계 변수에 더했겠지만, SQL에서는 SUM() 한 단어로 끝납니다. 21장에서 비워 둔 실행 순서의 GROUP BY → HAVING 자리를 이 장에서 채웁니다.
개념
① 집계 함수 — 여러 행을 숫자 하나로
COUNT(개수) · SUM(합) · AVG(평균) · MAX/MIN(최대·최소)은 여러 행을 받아 값 하나를 돌려줍니다. 가장 중요한 규칙: COUNT(*)를 빼면 모두 NULL을 아예 없는 것으로 치고 계산합니다. 사원 14명 중 커미션이 있는 사람은 4명(0인 TURNER 포함)이라 AVG(comm)은 2200÷4 = 550이지 2200÷14가 아니에요. "전체 사원 기준 평균"이 필요하면 AVG(IFNULL(comm, 0))으로 NULL을 0으로 바꿔 줘야 합니다. 같은 이유로 COUNT(*)는 행을 세고(14), COUNT(comm)은 NULL이 아닌 값만 셉니다(4).
시험지를 반별로 나눠 쌓은 뒤 반마다 평균을 내는 것과 같아요. GROUP BY deptno를 쓰면 부서번호가 같은 행끼리 한 묶음이 되고, 집계 함수는 묶음마다 한 번씩 계산됩니다. 결과는 묶음 하나당 한 줄이에요. 그래서 SELECT에는 묶은 열과 집계 함수만 쓰는 것이 원칙입니다. 부서로 묶어 놓고 ename을 달라고 하면 묶음 안의 여러 이름 중 무엇을 보여 줄지 정할 수 없으니까요.
📎 쿼리 #1.sql 「group by 절」(sqldb), 「7.」 Q4·Q5 · 22일차
③ WHERE vs HAVING — 묶기 전에 거르나, 묶은 뒤에 거르나
실행 순서 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY를 떠올리면 답이 나옵니다. WHERE는 묶기 전에 행 하나하나를 거르고, HAVING은 묶은 뒤에 그룹을 거릅니다. 그래서 SUM(sal) > 5000 같은 집계 조건은 HAVING에만 쓸 수 있어요(WHERE 시점에는 아직 합계가 없습니다). 반대로 "MANAGER는 빼고"처럼 행 하나만 보고 판단할 수 있는 조건은 WHERE에 둬야 합니다 — 미리 걸러서 묶을 양이 줄어드니 빠르기도 하고요.
GROUP BY는 여러 줄을 한 줄로 접어 버려서 사원 이름이 사라집니다. 성적표처럼 "내 점수 옆에 반 평균"을 같이 보고 싶을 때 쓰는 것이 윈도우 함수예요. 함수() OVER(PARTITION BY 묶을열 ORDER BY 순서열) 형태로, PARTITION BY는 "행을 합치지 않는 GROUP BY"입니다. 순위 함수 세 가지는 동점에서 갈립니다 — 키 182가 두 명이면 ROW_NUMBER는 2·3(억지로 가름), RANK는 2·2 다음 4(건너뜀), DENSE_RANK는 2·2 다음 3(안 건너뜀). 윈도우 함수는 SELECT 단계에서 계산되므로 WHERE에서는 쓸 수 없고, 순위로 거르려면 23장의 인라인 뷰로 한 겹 감쌉니다.
📎 쿼리 #1.sql 「윈도우 함수 사용하기」, 「11. 함수 이용하기(윈도우함수)」 · 24일차
예제 — 수업 쿼리로 확인하기
회원별 총 구매 개수 · 총 구매액 (sqlDB)
이 쿼리는 GROUP BY가 12줄짜리 구매 기록을 회원 수만큼의 줄로 접는다는 것을 보여 주려고 만든 실습입니다. 집계 함수 안에서 price * amount처럼 계산을 먼저 한 뒤 더할 수도 있어요.
SQL쿼리 #1.sql — group by 절(22일차)
USE sqldb;
-- group by 절
SELECT userid, SUM(amount) AS '총 구매 개수'
FROM buytbl
GROUP BY userid;
SELECT userid AS '사용자 아이디' ,SUM(price * amount) AS '총 구매액'
FROM buytbl
GROUP BY userid;
-- BBK 1920 · EJW 95 · JYP 200 · KBS 1210 · SSK 75 (12행 → 5행)
👀 관찰 포인트 — 구매 기록은 12줄인데 결과는 구매한 회원 수인 5줄입니다. BBK의 1920은 모니터 200×5 + 메모리 80×10 + 운동화 30×2 + 운동화 30×2를 더한 값이에요. 한 번도 안 산 회원 5명(이승기 등)은 buytbl에 아예 없으니 결과에도 없습니다 — 이들을 0으로라도 보이게 하려면 23장의 OUTER JOIN이 필요해요.
WHERE와 HAVING을 한 문장에 — 그리고 자리를 잘못 고른 답안
이 문제들은 "행 조건은 WHERE, 그룹 조건은 HAVING"을 한 문장 안에서 나눠 쓰는 연습을 시키려고 낸 것입니다. 마지막 Q4는 수업 답안이 실제로 자리를 잘못 고른 사례라 같이 봅니다.
SQL쿼리 #1.sql — 7. Q6 · 8. Q3 · Q8 · Q11 · Q4
USE scott;
-- 7. Q6) 사원 테이블에서 평균 커미션(COMM)을 출력하자.
SELECT AVG(comm)
FROM emp; -- 550.0000 (NULL 10명은 빼고 4명으로 나눔)
-- 8. Q3) 커미션이 책정된 사원은 모두 몇 명인지
SELECT count(comm)
FROM emp
WHERE comm IS NOT NULL; -- 4
-- 8. Q8) 직업별 총 월급, MANAGER 제외, 총 월급이 5000보다 큰 직업만
SELECT job, sum(sal)
FROM emp
WHERE job != 'manager' -- 행 조건 → WHERE
GROUP BY job
HAVING SUM(sal)>5000; -- 그룹 조건 → HAVING : SALESMAN 5600, ANALYST 6000
-- 11) 부서별 평균 월급, 커미션이 책정된 사원만, 평균 1000 이상, 높은 순
SELECT deptno, AVG(sal)
FROM emp
WHERE comm IS NOT NULL
GROUP BY deptno
HAVING AVG(sal) >= 1000
ORDER BY AVG(sal) DESC; -- 30번 부서 1400 한 줄
-- 8. Q4) 직업이 SALESMAN이고 월급이 1000 이상인 사원의 이름과 월급 (수업 답안)
SELECT ename, sal
FROM emp
WHERE job = 'salesman'
GROUP BY ename
HAVING sal >= 1000;
👀 관찰 포인트 — Q8에서 PRESIDENT의 합계는 정확히 5000이라 > 5000에 걸려 빠집니다. Q11은 커미션이 있는 4명이 모두 30번 부서라 결과가 한 줄뿐이에요. Q4는 MariaDB 기본 설정에서 에러 없이 4명이 나오지만, "월급 1000 이상"은 행 하나만 보고 판단하는 조건이라 HAVING이 아니라 WHERE 자리입니다. 묶을 필요도 없어요: WHERE job = 'SALESMAN' AND sal >= 1000.
GROUP BY로는 안 되던 "내 옆에 부서 평균" — 윈도우 함수
이 두 쿼리는 GROUP BY와 윈도우 함수가 어떻게 다른지 보여 주려고 나란히 실행한 것입니다. 첫 번째가 수업에서 먼저 시도한 방법이고, 두 번째가 답이에요. 이어서 같은 키 순위를 세 가지 순위 함수로 매겨 봅니다.
SQL쿼리 #1.sql — 윈도우 함수 사용하기(24일차)
-- 부서별 평균 급여 (첫 시도)
USE scott;
SELECT ename, deptno,ROUND( AVG(sal), 2) AS "avg_sal"
FROM emp
GROUP BY deptno, ename; -- 이름까지 묶으면 묶음마다 1명 → 평균 = 자기 월급
-- 윈도우 함수 사용: 집계 대상 데이터외에 다른 데이터도 표현
SELECT ename, deptno,
ROUND( AVG(sal) OVER(PARTITION BY deptno), 2) AS "avg_sal"
FROM emp
GROUP BY deptno, ename; -- 10번 2916.67 · 20번 2175.00 · 30번 1566.67
USE sqldb;
SELECT ROW_NUMBER() OVER(ORDER BY height DESC) `키큰순위`,
NAME, addr, height
FROM usertbl;
-- rank() 사용하기
SELECT rank() OVER(ORDER BY height DESC) `키큰순위`,
NAME, addr, height
FROM usertbl;
-- dense_rank() 사용하기
SELECT dense_rank() OVER(ORDER BY height DESC) `키큰순위`,
NAME, addr, height
FROM usertbl;
👀 관찰 포인트 — 첫 시도는 ename까지 묶어 버려서 "부서 평균"이 아니라 각자의 월급이 나옵니다. 두 번째는 14줄이 그대로 있고 옆 칸에 부서 평균이 붙어요(여기서 GROUP BY deptno, ename은 한 사람이 한 묶음이라 없어도 결과가 같습니다). 순위는 성시경 186이 셋 다 1, 키 182인 이승기·임재범이 ROW_NUMBER 2·3 / RANK 2·2 / DENSE_RANK 2·2, 다음 김경호 177이 4 / 4 / 3입니다. ROW_NUMBER에서 두 동점자 중 누가 2번이 될지는 정해져 있지 않아요.
⚠️ 수업 쿼리에서 조심할 곳
① 8번 Q5 SELECT deptno AVG(sal)은 쉼표가 빠져 문법 에러(ERROR 1064)입니다 — deptno, AVG(sal). ② 7번 Q10 SELECT job, max(sal) FROM emp WHERE job = 'salesman';처럼 GROUP BY 없이 일반 열과 집계를 섞으면 MySQL 5.7+(ONLY_FULL_GROUP_BY)는 에러로 막지만 MariaDB 기본 설정은 통과시킵니다. 여기서는 모든 행이 SALESMAN이라 우연히 맞을 뿐이니, 묶은 열과 집계만 SELECT에 쓰는 습관을 들이세요. ③ 11번 문제 2의 WHERE RN BETWEEN 6 A.ND 10은 AND 오타라 ERROR 1064입니다.
핵심 정리
집계 함수는 NULL을 빼고 계산한다(COUNT(*)만 예외). 전체 기준이 필요하면 IFNULL(열, 0).
COUNT(*)=행 수, COUNT(열)=NULL 아닌 값의 수. 이 차이는 23장 OUTER JOIN에서 다시 함정이 된다.
GROUP BY는 묶음 하나당 한 줄. SELECT에는 묶은 열과 집계만.
WHERE = 묶기 전 행 조건, HAVING = 묶은 뒤 그룹 조건. 집계 조건은 반드시 HAVING, 행 조건은 WHERE.
윈도우 함수 OVER(PARTITION BY …)는 행을 접지 않고 칸을 늘린다. 동점: ROW_NUMBER 2·3 / RANK 2·2·4 / DENSE_RANK 2·2·3.
확인 문제"총 월급이 5000보다 큰 직업"을 WHERE SUM(sal) > 5000으로 쓰면 왜 안 되나요?
WHERE는 GROUP BY보다 먼저 실행되어 행 하나씩만 봅니다. 그 시점에는 아직 묶음도 합계도 없어서 집계 함수를 쓸 수 없어요(에러). 묶은 뒤에 도는 HAVING SUM(sal) > 5000에 써야 합니다.
SELECT COUNT(*), COUNT(comm), AVG(comm) FROM emp;의 결과는?
14, 4, 550.0000. COUNT(*)는 행 14개를 모두 세고, COUNT(comm)은 NULL이 아닌 4개(300·500·1400·0)만, AVG는 그 4개의 합 2200을 4로 나눕니다.
월급 순위 6~10등을 뽑으려고 WHERE ROW_NUMBER() OVER(ORDER BY sal DESC) BETWEEN 6 AND 10이라고 썼습니다. 무엇이 문제이고 어떻게 고치나요?
윈도우 함수는 SELECT 단계에서 계산되므로 그보다 먼저 도는 WHERE에서는 쓸 수 없습니다. 수업 11번 문제 2처럼 SELECT * FROM (SELECT *, ROW_NUMBER() OVER (ORDER BY sal DESC) RN FROM emp) a WHERE RN BETWEEN 6 AND 10; — 인라인 뷰로 순위를 먼저 만들고 바깥에서 거릅니다(23장).
다음 장으로
지금까지는 표 하나 안에서만 골라내고 요약했어요. 그런데 사원 표에는 부서 번호만 있고 부서 이름은 부서 표에 있습니다. 23장에서는 흩어진 표를 이어 붙이는 JOIN과, 쿼리 안에 쿼리를 넣는 서브쿼리를 배웁니다.
JOIN … ON으로 두 표를 잇고, INNER(짝 있는 행만)와 LEFT/RIGHT OUTER(기준 쪽 전부)의 차이를 결과 행 수로 설명할 수 있다.
같은 표를 두 번 부르는 셀프 조인을 쓸 수 있고, OUTER JOIN 뒤 COUNT(*)의 함정을 피할 수 있다.
서브쿼리를 단일행 · 다중행 · 스칼라 · 인라인 뷰 · CTE로 구분하고, NOT IN에 NULL이 섞이면 0행이 되는 이유를 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
21·22장은 표 하나 안의 이야기였어요. 그런데 DB는 같은 정보를 여러 번 적지 않으려고 표를 일부러 나눠 둡니다(정규화). 구매 표에는 회원 아이디만 있고 이름·주소는 회원 표에 있죠. 자바에서 객체가 다른 객체를 참조하던 것처럼, 표는 외래키로 다른 표를 가리킵니다. 이 장은 그 화살표를 따라가 두 표를 한 결과로 합치는 두 가지 방법 — JOIN(옆으로 붙이기)과 서브쿼리(먼저 물어보고 그 답으로 다시 묻기)를 다룹니다.
개념
① INNER JOIN — 짝이 맞는 행끼리 옆으로 붙이기
FROM buytbl b JOIN usertbl u ON b.userid = u.userid는 "구매 기록 한 줄마다 아이디가 같은 회원 줄을 찾아 옆에 붙여라"라는 뜻입니다. b, u는 테이블 별칭이에요. 두 표에 같은 이름의 열(userid)이 있으니 b.userid처럼 어느 쪽인지 밝혀야 합니다. JOIN만 쓰면 INNER JOIN이고, 짝이 있는 행만 남습니다. ON을 빠뜨리면 모든 행이 모든 행과 붙는 카티시안 곱(12×10 = 120줄)이 됩니다. 조인 조건이 꼭 =일 필요는 없어서, 급여 등급처럼 구간으로 잇는 ON e.sal BETWEEN s.losal AND s.hisal(비등가 조인)도 수업에서 썼습니다.
LEFT OUTER JOIN은 왼쪽 표의 행을 전부 남기고, 오른쪽에 짝이 없으면 그 칸을 NULL로 채웁니다(RIGHT는 반대). 그래서 "한 번도 안 산 회원"은 LEFT JOIN 후 WHERE b.prodName IS NULL로 찾아요. 사원과 그 관리자는 둘 다 emp 표에 있으니 같은 표를 별칭 두 개로 두 번 부릅니다(emp e1 = 사원, emp e2 = 관리자). 이것이 셀프 조인입니다.
함정 하나: OUTER JOIN이 만든 NULL 줄도 한 줄이라 COUNT(*)는 그것을 1로 셉니다. 사원이 0명인 부서가 1명으로 나오는 거죠. 짝 쪽의 열을 세는 COUNT(e.ename)을 써야 NULL이 빠져 0이 됩니다(22장 ①의 규칙 그대로).
괄호 안의 SELECT가 먼저 답을 내고, 바깥 쿼리가 그 답을 씁니다. "BLAKE의 부서 위치"처럼 두 번 물어야 하는 것을 한 번에 묻는 방법이에요.
단일행 — 안쪽이 값 하나를 내면 =>로 비교. 다중행 — 여러 행이면 IN·ANY·ALL(> ALL(…)은 "최댓값보다 크다", >= ANY(…)는 "최솟값 이상"). 스칼라 서브쿼리 — 결과가 딱 값 하나(1행 1열)라서 값이 들어갈 자리면 어디든(WHERE sal > (SELECT AVG(sal) FROM emp), SELECT 절의 열 자리 등) 씁니다. 인라인 뷰 — FROM 자리에 넣어 결과를 임시 표처럼 쓰며 별칭이 필수. CTE — WITH 이름 AS (…)로 그 조각을 맨 위로 빼서 이름을 붙인 것인데, 그 이름은 붙어 있는 한 문장 안에서만 유효합니다.
이 세 쿼리는 INNER와 OUTER가 결과 행 수에서 어떻게 달라지는지, 그리고 OUTER JOIN이 만든 NULL로 "없는 것"을 찾는 법을 보여 주려고 짠 실습입니다.
SQL쿼리 #1.sql — join하기 · outer join하기(23·25일차)
USE sqldb;
SELECT b.userid, NAME, prodname, addr,
CONCAT(mobile1, mobile2) AS "연락처"
FROM buytbl b
JOIN usertbl u
ON b.userid = u.userid
WHERE b.userid = 'jyp'; -- 조용필 · 모니터 · 경기 · 01144444444
-- 기준테이블 usertbl일 경우
SELECT u.userid,u.name,b.prodName, u.addr,
CONCAT(u.mobile1, u.mobile2) AS "연락처"
FROM usertbl u
LEFT OUTER JOIN buytbl b
ON u.userID = b.userID
ORDER BY u.userID; -- 17행 = 구매 12행 + 안 산 회원 5명
-- 구매이력이 없는 회원 조회할 경우
SELECT u.userid,u.name,b.prodName, u.addr,
CONCAT(u.mobile1, u.mobile2) AS "연락처"
FROM usertbl u
LEFT OUTER JOIN buytbl b
ON u.userID = b.userID
WHERE b.prodName IS NULL
ORDER BY u.userID; -- JKW 조관우, KKH 김경호, LJB 임재범, LSG 이승기, YJS 윤종신
👀 관찰 포인트 — INNER JOIN으로 전체를 붙이면 구매 기록 수인 12행인데, LEFT JOIN은 안 산 회원 5명이 prodName NULL로 더해져 17행이 됩니다. 또 성시경은 휴대폰 번호가 NULL이라 CONCAT(mobile1, mobile2)의 결과도 NULL이에요 — CONCAT은 재료 중 하나라도 NULL이면 통째로 NULL이 됩니다.
셀프 조인과 "부서별 사원 수" — COUNT(*)의 함정
이 쿼리들은 같은 표를 두 번 부르는 셀프 조인과, INNER→OUTER로 바꿀 때 사원 0명인 부서가 살아나고 그때 무엇을 세야 하는지 보여 주려고 푼 문제입니다. Q10에 답이 두 개 있는 이유가 바로 그 함정이에요.
SQL쿼리 #1.sql — 12. join 이용하기 Q9 · Q10(25일차)
USE scott;
-- Q9) 사원번호·사원이름 + 그 사원을 관리하는 관리자의 사원번호·사원이름
SELECT e1.empno, e1.ename, e2.empno, e2.ename
FROM emp e1 -- e1 = 사원
JOIN emp e2 -- e2 = 관리자 (같은 표를 한 번 더)
ON e1.mgr = e2.empno; -- 13행 : KING은 mgr이 NULL이라 짝이 없어 빠진다
-- Q10) 부서이름, 위치, 사원 수, 평균 급여
SELECT d.dname "DNAME", d.loc "LOC",
COUNT(*) "NUMBER OF PEOPLE", round(AVG(sal)) "SALARY"
FROM emp e JOIN dept d
ON e.deptno = d.deptno
GROUP BY d.dname; -- 3개 부서만 (OPERATIONS가 사라짐)
SELECT d.dname "DNAME", d.loc "LOC",
COUNT(e.EName) "NUMBER OF PEOPLE", round(AVG(sal)) "SALARY"
FROM emp e RIGHT OUTER JOIN dept d
ON e.deptno = d.deptno
GROUP BY d.dname; -- 4개 부서
👀 관찰 포인트 — 두 번째 답의 결과는 ACCOUNTING 3명·2917, RESEARCH 5명·2175, SALES 6명·1567, OPERATIONS 0명·NULL입니다. 여기서 COUNT(e.EName)을 COUNT(*)로 바꾸면 OPERATIONS가 1명으로 나와요 — NULL로 채운 줄 하나를 행으로 셌기 때문입니다. 평균도 0이 아니라 NULL이라는 점을 보세요(값이 하나도 없으니 "모름").
서브쿼리 다섯 모양 — 단일행 · 다중행 · NOT IN · 인라인 뷰 · CTE
이 쿼리들은 서브쿼리를 어디에 넣느냐(WHERE·FROM·WITH)와 몇 행을 돌려주느냐(하나·여럿)에 따라 쓰는 법이 달라진다는 것을 보여 주려고 푼 문제들입니다. 05번은 NULL 함정을 피하려고 NVL을 넣은 답이에요.
USE scott;
-- 4-03. 'BLAKE'가 근무하는 부서의 위치(LOC) — 단일행(=)
SELECT loc
FROM dept
WHERE deptno = (SELECT deptno FROM emp WHERE ename = 'blake'); -- CHICAGO
-- 3-04. 부하직원이 있는 사원 — 다중행(IN)
SELECT empno, ename
FROM emp
WHERE empno IN (SELECT mgr FROM emp); -- JONES, BLAKE, CLARK, SCOTT, KING, FORD
-- 3-05. 부하직원이 없는 사원
-- NVL(컬럼,지정값): 해당 컬럼의 값들 중에 null을 찾아서 지정한 값으로 대체
SELECT empno, ename
FROM emp
WHERE empno NOT IN (
SELECT NVL(mgr, 0)
FROM emp
); -- 8명 (NVL을 빼면 0행!)
-- 인라인 뷰 사용 -> 반드시 별칭을 정의해야 함
SELECT empno, ename, sal
FROM
(SELECT empno, ename, sal
FROM emp
WHERE deptno IN (SELECT deptno FROM emp WHERE ename LIKE '%S%')
) AS a
WHERE sal > (SELECT AVG(sal) FROM emp); -- JONES, BLAKE, SCOTT, FORD
-- CTE 사용하기
USE sqldb;
WITH abc(userid, total)
AS
(SELECT userid, SUM(price*amount)
FROM buytbl
GROUP BY userid)
SELECT * FROM abc ORDER BY total DESC; -- 하나의 묶음으로 실행
SELECT * FROM abc ORDER BY total DESC; -- 재실행은 안됨
👀 관찰 포인트 — (SELECT AVG(sal) FROM emp)는 값 하나(약 2073)를 내는 스칼라 서브쿼리라 > 옆에 그냥 놓였어요. 인라인 뷰 AS a를 지우면 문법 에러가 납니다. CTE의 첫 문장은 BBK 1920 → KBS 1210 → JYP 200 → EJW 95 → SSK 75로 나오지만, 바로 다음 문장에서 abc를 부르면 "그런 표가 없다"는 에러예요. 이름이 한 문장짜리 임시 이름이기 때문입니다(계속 쓰려면 25장의 뷰).
⚠️ NOT IN과 NULL — 에러 없이 0행
WHERE empno NOT IN (SELECT mgr FROM emp)처럼 NVL 없이 쓰면 결과가 0행입니다. KING의 mgr이 NULL이라 목록에 NULL이 섞이기 때문이에요. x NOT IN (a, b, NULL)은 x<>a AND x<>b AND x<>NULL로 풀리고, 마지막이 UNKNOWN이라 전체가 절대 참이 될 수 없습니다. 반대로 IN은 OR로 풀려서 NULL이 섞여도 멀쩡해요. 수업 답안의 NVL은 원래 Oracle 함수인데 MariaDB는 지원해서 돌아갑니다. 다만 MySQL에는 없으니 어디서나 통하는 IFNULL(mgr, 0)이나 표준 COALESCE(mgr, 0)을 쓰거나, NULL 문제가 아예 없는 NOT EXISTS를 쓰는 편이 안전합니다.
핵심 정리
A JOIN B ON 조건 = INNER: 짝 있는 행만. ON을 빠뜨리면 카티시안 곱.
LEFT/RIGHT OUTER JOIN: 기준 쪽은 전부, 짝이 없으면 NULL. "없는 것 찾기" = LEFT JOIN + WHERE 오른쪽열 IS NULL.
셀프 조인 = 같은 표를 다른 별칭으로 두 번. 비등가 조인 = ON … BETWEEN ….
OUTER JOIN 뒤 개수는 COUNT(*)가 아니라 COUNT(짝쪽 열).
서브쿼리: 값 하나면 =, 여러 개면 IN/ANY/ALL. FROM 서브쿼리(인라인 뷰)는 별칭 필수, CTE 이름은 그 한 문장 안에서만.
NOT IN 목록에 NULL이 하나라도 있으면 0행 → IFNULL/COALESCE/NOT EXISTS.
확인 문제셀프 조인 Q9의 결과가 14행이 아니라 13행인 이유는? 14명 모두 보이게 하려면?
KING은 mgr이 NULL이라 e1.mgr = e2.empno를 만족하는 짝이 없어 INNER JOIN에서 빠집니다. FROM emp e1 LEFT OUTER JOIN emp e2 ON e1.mgr = e2.empno로 바꾸면 KING도 관리자 칸이 NULL인 채로 나와 14행이 됩니다.
sqlDB에서 WHERE height >= ALL (SELECT height FROM usertbl WHERE addr = '경남')은 결국 어떤 조건과 같나요?
경남 회원의 키는 173(김범수)·170(윤종신)입니다. >= ALL은 "그 모두보다 크거나 같다" = 최댓값 173 이상과 같아요. 7명이 나옵니다. >= ANY였다면 "하나라도" = 최솟값 170 이상이라 조용필(166)만 빠진 9명입니다.
"사원 이름과 그 사원의 부서 이름"을 출력할 때 WHERE 절 서브쿼리보다 JOIN이 자연스러운 이유는?
WHERE 절 서브쿼리는 조건을 빌려 오기만 할 뿐, 부서 표의 열(dname)을 결과에 내보낼 수 없습니다. 두 표의 열을 함께 출력하려면 JOIN으로 옆에 붙여야 해요. (SELECT 절 스칼라 서브쿼리로 한 열씩 가져올 수도 있지만, 열이 여러 개면 JOIN이 간결합니다.)
다음 장으로
21~23장은 모두 읽기였어요. 24장에서는 표를 직접 만들고(CREATE), 채우고(INSERT), 고치고(UPDATE), 지웁니다(DELETE). 이 장에서 따라간 외래키가 "없는 회원의 구매는 못 넣게" 막는 장면과, 실수를 되돌리는 트랜잭션도 거기서 봅니다.
SQL 문장을 DDL(구조) · DML(데이터) · DCL(권한)로 나누고, CREATE TABLE·INSERT·UPDATE·DELETE를 쓸 수 있다.
PK · FK · NOT NULL · CHECK 제약조건이 무엇을 막는지, ON DELETE/UPDATE CASCADE가 무엇을 하는지 설명할 수 있다.
START TRANSACTION · COMMIT · ROLLBACK으로 여러 변경을 한 묶음으로 다루고, autocommit 환경에서 왜 그냥 실행한 DELETE는 되돌릴 수 없는지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
23장까지는 남이 만들어 둔 표를 읽기만 했어요. 이제 표를 직접 설계하고 데이터를 바꿉니다. 자바로 치면 CREATE TABLE은 클래스 정의, INSERT는 객체 생성, 제약조건은 setter에서 값을 검사하던 캡슐화와 같은 역할이에요. 다만 읽기와 달리 바꾸는 명령은 실수하면 되돌리기 어렵습니다. 그래서 이 장의 절반은 "어떻게 안전하게 바꾸나"입니다.
개념
① SQL 3분류와 CREATE TABLE · INSERT
DDL(Definition)은 표의 구조를 다룹니다: CREATE · ALTER · DROP · TRUNCATE. DML(Manipulation)은 표 안의 데이터를 다룹니다: SELECT · INSERT · UPDATE · DELETE. DCL은 권한(GRANT · REVOKE)입니다. CREATE TABLE에서는 열마다 자료형을 정해요 — 길이가 일정한 아이디는 CHAR(8)(고정 길이), 들쭉날쭉한 이름은 VARCHAR(10)(가변 길이), 숫자는 INT·SMALLINT, 날짜는 DATE. AUTO_INCREMENT 열에 NULL을 넣으면 DB가 번호를 1, 2, 3… 채워 줍니다.
PRIMARY KEY(PK)는 행을 구별하는 열로, 중복과 NULL이 모두 금지됩니다. FOREIGN KEY(FK)는 "이 값은 저 표의 PK에 실제로 있는 값이어야 한다"는 규칙이라, 없는 회원의 구매를 넣으면 ERROR 1452로 거부합니다. 그래서 부모(회원)를 먼저 만들고 먼저 넣어야 해요. CHECK는 값의 범위를 강제합니다(위반 시 MariaDB ERROR 4025). 제약조건은 CREATE 때 붙여도 되고 ALTER TABLE … ADD CONSTRAINT로 나중에 붙여도 됩니다. FK에 ON UPDATE CASCADE · ON DELETE CASCADE를 달면 부모의 값이 바뀌거나 지워질 때 자식 행도 따라서 바뀌거나 지워집니다.
📎 쿼리 #1.sql 「테이블과 뷰」 tabledb 교재실습 · 26일차
③ UPDATE · DELETE — WHERE 한 줄이 생명줄
UPDATE 표 SET 열 = 값 WHERE 조건은 조건에 맞는 행을 고치고, DELETE FROM 표 WHERE 조건은 지웁니다. WHERE를 빠뜨리면 에러도 경고도 없이 모든 행이 대상이 됩니다. 그래서 같은 조건으로 SELECT를 먼저 돌려 원하는 행만 나오는지 확인한 뒤 UPDATE/DELETE로 바꾸는 습관을 들이세요. 지우는 명령은 셋입니다 — DELETE(DML, 골라서 행 삭제, 번호는 이어짐), TRUNCATE(DDL, 행 전체를 비우고 AUTO_INCREMENT도 처음으로), DROP(DDL, 표 자체를 없앰).
📎 쿼리 #1.sql 「연습문제 9」, 「삭제하는 기능 3가지」 · 23일차
④ 트랜잭션 — 전부 되거나, 전부 안 되거나
계좌 이체의 "A에서 빼기"와 "B에 넣기"는 둘 다 되거나 둘 다 안 돼야 합니다. 이렇게 여러 DML을 한 묶음으로 다루는 단위가 트랜잭션이에요. START TRANSACTION;으로 시작해, 다 잘됐으면 COMMIT;(확정), 문제가 있으면 ROLLBACK;(시작 시점으로 되돌림)합니다. 중요한 것: MariaDB와 HeidiSQL은 autocommit이 켜져 있어서, START TRANSACTION 없이 실행한 문장은 한 문장마다 즉시 확정됩니다. 그냥 실행한 DELETE 뒤에 ROLLBACK을 쳐도 돌아오지 않아요. 또 CREATE · ALTER · DROP · TRUNCATE 같은 DDL은 실행되는 순간 그때까지의 작업까지 자동으로 커밋하므로 되돌릴 수 없습니다.
📎 수업 쿼리 파일에는 트랜잭션 문장이 없음 — 23일차 수업 설명 · 트랜잭션 개념 카드
예제 — 수업 쿼리로 확인하기
sqlDB 설계 — 회원 표와 구매 표, 외래키로 잇기
이 스크립트는 자료형 · NOT NULL · PK · AUTO_INCREMENT · FK를 한 번에 보여 주려고 만든 실습 DB입니다. 21~23장의 모든 sqlDB 예제가 이 두 표 위에서 돌았어요.
SQLsqldb생성.sql — 회원(userTbl) · 구매(buyTbl)
CREATE TABLE userTbl -- 회원 테이블
( userID CHAR(8) NOT NULL PRIMARY KEY, -- 사용자 아이디(PK)
name VARCHAR(10) NOT NULL, -- 이름
birthYear INT NOT NULL, -- 출생년도
addr CHAR(2) NOT NULL, -- 지역(경기,서울,경남 식으로 2글자만입력)
mobile1 CHAR(3), -- 휴대폰의 국번(011, 016, 017, 018, 019, 010 등)
mobile2 CHAR(8), -- 휴대폰의 나머지 전화번호(하이픈제외)
height SMALLINT, -- 키
mDate DATE -- 회원 가입일
);
CREATE TABLE buyTbl -- 회원 구매 테이블
( num INT AUTO_INCREMENT NOT NULL PRIMARY KEY, -- 순번(PK)
userID CHAR(8) NOT NULL, -- 아이디(FK)
prodName CHAR(6) NOT NULL, -- 물품명
groupName CHAR(4) , -- 분류
price INT NOT NULL, -- 단가
amount SMALLINT NOT NULL, -- 수량
FOREIGN KEY (userID) REFERENCES userTbl(userID) -- 외래키 지정
);
INSERT INTO userTbl VALUES('LSG', N'이승기', 1987, N'서울', '011', '11111111', 182, '2008-8-8');
-- …(생략: 회원 9명 더)
INSERT INTO buyTbl VALUES(NULL, 'KBS', N'운동화', NULL , 30, 2);
INSERT INTO buyTbl VALUES(NULL, 'KBS', N'노트북', N'전자', 1000, 1);
-- …(생략: 구매 10건 더)
👀 관찰 포인트 — mobile1·groupName처럼 NOT NULL이 없는 열만 NULL을 넣을 수 있습니다. 구매 INSERT의 첫 값 NULL은 "번호는 DB가 매겨라"라는 뜻(AUTO_INCREMENT)이에요. 그리고 userTbl을 먼저 만들고 먼저 채운 순서에 주목하세요 — 반대로 하면 FK가 "그런 회원 없음"으로 막습니다.
제약조건을 나중에 붙이고, CASCADE로 부모를 따라가게 하기 (tabledb)
이 실습은 제약조건을 ALTER로 뒤늦게 붙일 때 기존 데이터가 규칙을 어기면 안 붙는다는 것, 그리고 CASCADE가 부모 변경을 자식에게 전파한다는 것을 보여 주려고 한 것입니다. 제약조건 없이 만든 표에 데이터를 먼저 넣고 시작해요.
SQL쿼리 #1.sql — 테이블과 뷰 · 교재실습(26일차)
USE TABLEdb;
-- …(생략: 제약조건 없이 usertbl·buytbl 생성, 데이터 입력 — buytbl에는 usertbl에 없는 'bbk'의 구매가 있다)
ALTER TABLE usertbl
ADD CONSTRAINT pk_usertbl_userid
PRIMARY KEY (userid);
DELETE FROM buytbl WHERE userid = 'bbk'; -- 부모에 없는 값을 먼저 치워야 FK가 붙는다
ALTER TABLE buytbl
ADD CONSTRAINT fk_usertbl_buytbl
FOREIGN KEY (userid)
REFERENCES usertbl (userid);
-- …(생략: foreign_key_checks·CHECK 실습, 회원 입력)
ALTER TABLE buytbl
DROP FOREIGN KEY fk_usertbl_buytbl;
ALTER TABLE buytbl
ADD CONSTRAINT fk_usertbl_buytbl
FOREIGN KEY(userid)
REFERENCES usertbl (userid)
ON UPDATE CASCADE
ON DELETE CASCADE;
UPDATE usertbl SET userid = 'vvk' WHERE userid = 'bbk'; -- buytbl의 bbk 구매도 vvk로 바뀐다
-- …(생략: 조인으로 확인)
DELETE FROM usertbl WHERE userid = 'vvk'; -- buytbl의 vvk 구매도 함께 지워진다
👀 관찰 포인트 — PK가 먼저 있어야 다른 표가 그 열을 FK로 참조할 수 있습니다. CASCADE를 달기 전에는 부모의 bbk를 바꾸려면 FK 검사를 끄는(SET foreign_key_checks = 0) 편법을 써야 했지만, 달고 나서는 UPDATE 한 줄로 자식까지 따라 바뀝니다. 대신 ON DELETE CASCADE는 회원 한 명을 지우면 구매 기록이 소리 없이 같이 사라지니, 정말 그래도 되는 관계에만 답니다.
UPDATE · DELETE를 트랜잭션으로 감싸 보기
수업 쿼리는 UPDATE·DELETE의 문법과 WHERE의 역할을 보여 주려고 복사본 buytbl3에서 실습한 것입니다. 여기에 트랜잭션이 무엇을 되돌려 주는지 보여 주려고★추가 표시한 세 줄을 덧붙였어요(수업 파일에는 없음). 아래는 연습문제 9의 부호 실수입니다.
SQL쿼리 #1.sql — buytbl3 수정·삭제 실습 + 연습문제 9-1(23일차)
USE sqldb;
CREATE TABLE buytbl3 (SELECT * FROM buytbl); -- 원본 대신 복사본으로 연습 (제약조건은 복사 안 됨)
ALTER TABLE buytbl3
ADD CONSTRAINT pk_buytbl3_num PRIMARY KEY (num);
START TRANSACTION; -- ★추가: 여기부터 한 묶음
UPDATE buytbl3 SET price = 90, amount = 60, prodname = '노트북'
WHERE num = 6;
SELECT * FROM buytbl3;
DELETE FROM buytbl3 WHERE num = 2;
SELECT * FROM buytbl3; -- ★추가: 6번이 바뀌고 2번이 사라진 모습
ROLLBACK; -- ★추가: 둘 다 취소 → 원래대로
-- 연습문제 9) 1. 직업이 'SALESMAN'인 사원 급여(SAL)에 400 더하는 UPDATE
USE scott;
CREATE TABLE emp2 AS SELECT * FROM emp;
UPDATE emp2
SET sal = sal - 400
WHERE job = 'SALESMAN';
👀 관찰 포인트 — 트랜잭션 안에서는 바뀐 결과가 내 화면에는 보이지만 아직 확정이 아닙니다. ROLLBACK하면 6번(BBK 메모리 80×10)과 2번(KBS 노트북)이 그대로 돌아와요. START TRANSACTION 줄을 빼고 같은 일을 하면, autocommit 때문에 UPDATE·DELETE가 실행 즉시 확정되어 ROLLBACK이 아무것도 되돌리지 못합니다. 연습문제는 "400을 더하라"인데 sal - 400이라 ALLEN 1600이 1200이 됩니다. 문법이 맞아서 에러가 전혀 안 나는 실수예요 — sal = sal + 400이 맞습니다.
⚠️ 수업 쿼리에서 조심할 곳
① autocommit: MariaDB/HeidiSQL에서 START TRANSACTION 없이 DELETE하면 ROLLBACK으로 되돌릴 수 없습니다. 위험한 수정 전에는 트랜잭션을 먼저 여세요. ② SET foreign_key_checks = 0, SET check_constraint_checks = 0은 검사를 잠깐 끄는 것이라 그사이 들어간 규칙 위반 데이터(없는 회원의 구매, 1871년생 등)는 그대로 남습니다. 꼭 다시 1로 켜고, 실무 데이터에는 쓰지 마세요. ③ sqldb생성.sql은 첫 줄이 dDROP 오타이고, CREATE DATABASE sqlDB;가 IF NOT EXISTS 줄 뒤에 한 번 더 있어 통째로 실행하면 에러가 납니다. 첫 줄을 DROP으로 고치고 중복 줄을 빼고 실행하세요. ④ 연습문제 9-5의 DELETE FROM emp2 WHERE sal > (SELECT AVG(sal) FROM emp2)처럼 지우는 표를 서브쿼리에서 바로 읽는 것은 MySQL에서 ERROR 1093입니다. 수업 답안처럼 FROM (SELECT …) AS temp로 한 겹 감싸면 어느 쪽에서나 동작합니다.
PK = 중복·NULL 금지, FK = 부모에 있는 값만(위반 ERROR 1452), CHECK = 범위 강제(위반 ERROR 4025). 부모를 먼저 만들고 먼저 넣는다.
ON UPDATE/DELETE CASCADE = 부모가 바뀌거나 지워지면 자식도 따라간다.
UPDATE·DELETE에 WHERE가 없으면 전체 행. 같은 조건의 SELECT로 먼저 확인.
START TRANSACTION → COMMIT(확정) / ROLLBACK(취소). autocommit이 켜진 상태에서 그냥 실행한 DML과 모든 DDL은 되돌릴 수 없다.
확인 문제buyTbl에 INSERT INTO buyTbl VALUES(NULL, 'ZZZ', N'책', N'서적', 15, 1);을 실행하면? (ZZZ는 회원 표에 없음)
FK 위반으로 거부됩니다(ERROR 1452, Cannot add or update a child row). 회원 표에 없는 아이디의 구매는 들어갈 수 없어요. 먼저 userTbl에 ZZZ를 넣어야 합니다.
HeidiSQL에서 DELETE FROM buytbl3;을 실수로 실행했습니다. 바로 ROLLBACK;을 치면 살아날까요?
아니요. autocommit이 켜져 있어 DELETE가 실행 즉시 확정됐습니다. 게다가 WHERE가 없어서 전체 행이 지워졌어요. 미리 START TRANSACTION;을 실행해 둔 경우에만 ROLLBACK으로 되돌릴 수 있습니다.
DELETE FROM bigtbl1;과 TRUNCATE TABLE bigtbl3;은 둘 다 모든 행을 지웁니다. 차이 두 가지는?
① DELETE는 DML이라 트랜잭션 안이면 ROLLBACK할 수 있지만, TRUNCATE는 DDL이라 자동 커밋되어 되돌릴 수 없습니다. ② TRUNCATE는 AUTO_INCREMENT 번호를 처음으로 되돌리고, DELETE는 다음 번호가 이어집니다. (표 자체를 없애는 것은 DROP.)
다음 장으로
표를 만들고 지키는 법까지 왔어요. 25장에서는 만들어 둔 표를 더 안전하게(뷰로 필요한 열만 보여 주기), 더 빠르게(인덱스와 EXPLAIN), 더 편하게(자주 하는 작업을 프로시저로 저장) 쓰는 법을 배웁니다.
뷰가 데이터를 복사하지 않는 저장된 SELECT라는 것과 쓰는 이유(단순화·보안)를 말할 수 있다.
클러스터형 인덱스(PK, 테이블당 1개, 데이터 자체를 그 순서로 저장)와 보조 인덱스(여러 개, InnoDB에서는 PK 값을 들고 있음)를 구별할 수 있다.
EXPLAIN의 type·key·rows로 인덱스가 실제로 쓰였는지 확인하고, 안 쓰이는 대표 경우를 말할 수 있다.
DELIMITER를 바꿔 스토어드 프로시저를 만들고 CALL로 실행할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
24장에서 표를 만들고 제약조건으로 지켰어요. 이번에는 그 표를 쓰는 방식을 다듬습니다. 23장의 CTE는 한 문장짜리 이름이었죠 — 뷰는 그걸 DB에 저장해 두고 계속 쓰는 것입니다. 데이터가 수십만 행으로 커지면 "맞는 결과"만큼 "빠른 결과"가 중요해지는데, 그 열쇠가 인덱스이고, 정말 빨라졌는지 재는 도구가 EXPLAIN입니다. 마지막으로 SQL 여러 문장을 자바 메서드처럼 이름 붙여 저장하는 프로시저를 봅니다.
개념
① 뷰(VIEW) — 이름 붙여 저장한 SELECT
CREATE VIEW v_usertbl AS SELECT userid, NAME FROM usertbl;을 만들면 그다음부터 SELECT * FROM v_usertbl처럼 표처럼 씁니다. 하지만 데이터를 복사해 두는 게 아니라, 부를 때마다 원본을 다시 읽는 가상 테이블이라 원본이 바뀌면 뷰 결과도 바로 바뀝니다. 쓰는 이유는 셋 — ① 복잡한 조인 쿼리를 이름 하나로 단순화, ② 주소·전화번호를 뺀 뷰만 열어 주는 보안, ③ 자주 쓰는 조회의 재사용. 단순한 뷰를 통해 INSERT/UPDATE하면 원본 표가 바뀌므로 원본의 제약조건(뷰에 안 보이는 NOT NULL 열 등)에 걸릴 수 있습니다.
📎 쿼리 #1.sql 「view 사용하기」 · 26일차
② 인덱스 — 클러스터형(사전)과 보조(찾아보기)
클러스터형 인덱스는 영어사전과 같아요. 따로 색인이 있는 게 아니라 본문(데이터) 자체가 그 순서로 정렬되어 저장되니, 정렬 기준은 테이블당 하나뿐입니다. InnoDB에서는 PK가 클러스터형이 되고, PK가 없으면 UNIQUE NOT NULL 열이, 그것도 없으면 DB가 몰래 만든 숨은 행 번호가 그 역할을 합니다. 보조 인덱스는 책 뒤의 찾아보기예요. "값 → 그 행의 위치"를 따로 정렬해 적어 두는데, InnoDB의 보조 인덱스는 위치로 그 행의 PK 값을 적어 둡니다. 그래서 보조 인덱스로 찾으면 ① 보조 인덱스에서 PK를 얻고 ② 그 PK로 클러스터형 인덱스를 한 번 더 찾아가요. UNIQUE나 CREATE INDEX로 여러 개 만들 수 있습니다. 대가도 있어요 — INSERT·UPDATE·DELETE 때마다 인덱스도 고쳐야 해서 쓰기는 느려집니다.
쿼리 앞에 EXPLAIN을 붙이면 실행하지 않고 실행 계획만 보여 줍니다. 볼 칸은 셋: type(ALL이면 처음부터 끝까지 다 훑기, range는 범위만, const는 딱 한 건), key(실제로 고른 인덱스, NULL이면 안 씀), rows(읽을 것으로 예상한 행 수). 인덱스를 만들어도 옵티마이저가 안 쓰는 대표 경우 셋 — ① 조회 범위가 너무 넓을 때(보조 인덱스는 행마다 한 번 더 찾아가야 하니 차라리 전체를 훑는 게 쌈), ② 열을 연산·함수로 감쌀 때(emp_no*1 = 100000 ✗, emp_no = 100000/1 ✓ — 인덱스는 열의 원래 값으로 정렬돼 있으니까), ③ 값의 종류가 적을 때(성별 M/F처럼). 데이터를 대량으로 넣은 뒤에는 ANALYZE TABLE로 통계를 갱신해야 옵티마이저가 제대로 판단합니다.
📎 쿼리 #1.sql 「실습 4」 indexdb · 27일차
④ 스토어드 프로시저 · 함수 — SQL 여러 줄에 이름 붙이기
프로시저는 자바의 메서드처럼 이름 · 매개변수 · 본문이 있고, DB 서버 안에 저장됩니다. CREATE PROCEDURE 이름(IN p_값 INT) BEGIN … END로 만들고 CALL 이름(10)으로 실행해요. 본문 안에서는 DECLARE(변수 선언, BEGIN 바로 뒤에서만) · IF … END IF · WHILE 같은 절차적 문법을 씁니다. 본문에 세미콜론이 여러 개 들어가므로, 클라이언트(HeidiSQL)가 첫 ;에서 문장을 잘라 버리지 않게 DELIMITER $로 문장 끝 기호를 잠시 바꿨다가 DELIMITER ;로 되돌립니다. 함수(CREATE FUNCTION … RETURNS INT)는 값을 하나 돌려줘서 SELECT userFunc(100, 200)처럼 SELECT 안에서 쓰고, 프로시저는 CALL로만 부릅니다.
이 실습은 PK·UNIQUE를 걸면 인덱스가 저절로 생긴다는 것, 그리고 클러스터형 인덱스는 데이터의 저장 순서 자체를 바꾼다는 것을 보여 주려고 한 것입니다. 마지막 두 줄은 일부러 뒤죽박죽 섞은 표에 PK를 붙여 보는 실험이에요.
SQL쿼리 #1.sql — 인덱스 생성 실습(26일차)
CREATE TABLE tbl1 (a INT PRIMARY KEY, b INT, c INT);
SHOW INDEX from tbl1; -- PRIMARY(a) 하나
CREATE TABLE tbl2 (a INT PRIMARY KEY, b INT UNIQUE, c INT UNIQUE , d INT);
SHOW INDEX FROM tbl2; -- PRIMARY(a) = 클러스터형, b · c = 보조
-- unique와 not null을 설정하면 클러스터형 인덱스가 됨
CREATE TABLE tbl4(a INT UNIQUE NOT NULL , b INT UNIQUE, c INT UNIQUE, d INT);
SHOW INDEX FROM tbl4; -- PK가 없으니 a가 클러스터형 역할
CREATE TABLE tbl5(a INT UNIQUE NOT NULL , b INT UNIQUE, c INT UNIQUE, d INT PRIMARY KEY );
SHOW INDEX FROM tbl5; -- PK(d)가 있으니 d가 클러스터형, a는 보조
-- 클러스터형 인덱스로 지정한 열을 기준으로 정렬된다.
CREATE TABLE usertbl3(SELECT * FROM usertbl ORDER BY RAND());
ALTER TABLE usertbl3
ADD PRIMARY KEY (userid);
-- 조회해보니 userid 값들이 오름차순 정렬되어 조회됨
SELECT * FROM usertbl3;
👀 관찰 포인트 — ORDER BY를 안 썼는데도 usertbl3이 userid 순으로 나옵니다. PK를 붙이는 순간 데이터가 PK 순서로 다시 저장됐기 때문이에요. 하지만 이건 저장 방식 때문에 그렇게 보이는 것일 뿐 보장된 순서가 아니니, 정렬이 필요하면 언제나 ORDER BY를 쓰세요. SHOW INDEX 결과에는 "클러스터형"이라는 표시가 따로 없습니다 — 위 규칙(PK → UNIQUE NOT NULL)으로 판단합니다.
같은 데이터 세 벌로 EXPLAIN 비교하기 (indexdb)
이 실습은 인덱스가 있고 없음에 따라 DB가 읽는 행 수가 얼마나 달라지는지, 그리고 조건을 잘못 쓰면 인덱스가 있어도 못 쓴다는 것을 숫자로 보여 주려고 만든 것입니다. employees 샘플(원본 약 30만 행)을 뒤섞어 세 벌로 복사해 시작해요.
SQL쿼리 #1.sql — 실습 4(27일차)
USE indexdb;
CREATE TABLE emp SELECT * FROM employees.employees
ORDER BY RAND(); -- 인덱스 없음
CREATE TABLE emp_c SELECT * FROM employees.employees
ORDER BY RAND(); -- 클러스터형(PK)을 붙일 표
CREATE TABLE emp_se SELECT * FROM employees.employees
ORDER BY RAND(); -- 보조 인덱스를 붙일 표
ALTER TABLE emp_c
ADD PRIMARY KEY (emp_no);
SELECT * FROM emp_c LIMIT 20; -- 데이터가 정렬된다
ALTER TABLE emp_se
ADD INDEX idx_emp_se_em_no (emp_no);
SELECT * FROM emp_se LIMIT 20; -- 데이터가 정렬 안된다
-- 생성한 인덱스의 실제 적용을 하려면
ANALYZE TABLE emp, emp_c, emp_se;
-- 조회해서 성능 확인하기
EXPLAIN SELECT * FROM emp WHERE emp_no < 11000;
EXPLAIN SELECT * FROM emp_c WHERE emp_no < 11000;
EXPLAIN SELECT * FROM emp_se WHERE emp_no < 11000;
-- 쿼리문에 조건을 잘못 만들 경우
EXPLAIN SELECT * FROM emp_c WHERE emp_no = 100000;
EXPLAIN SELECT * FROM emp_c WHERE emp_no*1 = 100000; -- (x)
EXPLAIN SELECT * FROM emp_c WHERE emp_no = 100000/1; -- (o)
👀 관찰 포인트 — emp는 type = ALL, key = NULL, rows가 표 전체 행 수 수준입니다. emp_c·emp_se는 type = range로 바뀌고 rows가 1천 행 정도로 확 줄어요(emp_no는 10001부터 시작하니까). emp_no*1은 수학적으로 같은 조건인데도 ALL로 돌아갑니다. 또 emp_se는 인덱스가 있어도 SELECT *의 순서가 뒤죽박죽이에요 — 보조 인덱스는 데이터의 저장 순서를 바꾸지 않습니다.
부서번호를 받아 사원 목록을 보여 주는 프로시저 (연습문제13-1)
이 프로시저는 매개변수 · 변수 선언 · SELECT INTO · IF 분기를 한 번에 보여 주려고 낸 문제의 답입니다. "있으면 목록, 없으면 안내 문구"를 DB 안에서 판단해요. 마지막 두 줄의 CALL은 실행 예로 덧붙인 것입니다.
SQL쿼리 #1.sql — 연습문제13(프로시저) 문제 1(27일차)
DROP PROCEDURE IF EXISTS getDept_emp;
delimiter $
CREATE PROCEDURE getDept_emp(IN p_deptno INT)
BEGIN
-- 부서 존재 여부를 체크할 변수 선언
DECLARE v_cnt INT DEFAULT 0;
-- dept 테이블에 전달받은 부서번호가 있는지 확인
SELECT COUNT(*) INTO v_cnt
FROM dept
WHERE deptno = p_deptno;
-- 조건 분기
IF v_cnt > 0 THEN
-- 부서가 존재할 경우: 사원 정보 출력
SELECT ename, job, sal
FROM emp
WHERE deptno = p_deptno;
ELSE
-- 부서가 존재하지 않을 경우: 메시지 출력
SELECT '해당 부서가 없습니다.' AS '메시지';
END IF;
END $
delimiter ;
CALL getDept_emp(10); -- (실행 예) CLARK · KING · MILLER
CALL getDept_emp(99); -- (실행 예) 해당 부서가 없습니다.
👀 관찰 포인트 — SELECT COUNT(*) INTO v_cnt는 조회 결과를 화면에 내지 않고 변수에 담습니다. delimiter $ 두 줄을 빼고 만들면 클라이언트가 본문의 첫 ;(DEFAULT 0;)에서 문장을 잘라 보내 문법 에러(ERROR 1064)가 나요. p_(매개변수)·v_(변수) 접두어는 문법이 아니라 deptno 같은 열 이름과 헷갈리지 않게 하는 관례입니다.
⚠️ 수업 주석 바로잡기
① 수업 주석 「unique와 not null을 설정하면 클러스터형 인덱스가 됨」은 PK가 없을 때만 맞습니다. 바로 다음 tbl5처럼 PK가 따로 있으면 PK가 클러스터형이고 UNIQUE NOT NULL 열은 보조 인덱스예요. ② 「집계함수, join, union 등을 통한 뷰는 수정할 수 없다」는 대략 맞지만 정확히는 집계 · DISTINCT · GROUP BY · UNION이 있으면 수정 불가이고, JOIN 뷰는 한쪽 표의 열만 고치는 경우 등 제한적으로 허용됩니다. 처음에는 "뷰는 조회용"으로 쓰는 게 안전합니다. ③ 「index를 강제로 실행」 주석의 USE INDEX는 "이 인덱스 중에서 골라라"라는 권유라 옵티마이저가 전체 훑기가 낫다고 보면 여전히 안 씁니다. 정말 강제하려면 FORCE INDEX, 금지는 IGNORE INDEX예요. ④ 파일의 userProc·ifProc·whileProc 등은 CALL 줄만 있고 만드는 문장은 파일에 없습니다(대시보드 카드 참고).
핵심 정리
뷰 = 저장된 SELECT. 데이터를 복사하지 않아 원본이 바뀌면 같이 바뀐다. CTE는 한 문장용, 뷰는 계속 쓰는 이름.
클러스터형 인덱스 = 데이터 자체가 그 순서(PK)로 저장, 테이블당 1개. 보조 인덱스 = 값 + PK 값(InnoDB), 여러 개, 찾은 뒤 PK로 한 번 더 찾아간다.
인덱스는 읽기를 빠르게, 쓰기를 느리게 한다. 자주 검색하는 열에만.
EXPLAIN: type(ALL=전체 훑기) · key(NULL=안 씀) · rows. 넓은 범위 · 열을 감싼 연산 · 적은 값 종류에서는 인덱스가 안 쓰인다. 열은 왼쪽에 맨몸으로.
프로시저는 CALL, 함수는 SELECT 안에서. 만들 때는 DELIMITER를 바꾸고, DECLARE는 BEGIN 바로 뒤에.
확인 문제hire_date에 인덱스가 있는데 WHERE YEAR(hire_date) = 1990이 느립니다. 이유와 고친 조건은?
열을 함수로 감싸서 인덱스의 정렬 순서를 쓸 수 없기 때문입니다(emp_no*1과 같은 경우). 열을 맨몸으로 두고 범위로 바꿉니다: WHERE hire_date >= '1990-01-01' AND hire_date < '1991-01-01'.
InnoDB에서 PK를 아주 긴 문자열로 잡으면 보조 인덱스까지 커지는 이유는?
보조 인덱스는 각 항목마다 "그 행의 위치"로 PK 값을 함께 저장하기 때문입니다. PK가 길면 모든 보조 인덱스의 모든 항목이 그만큼 길어져요. 그래서 PK는 짧은 값(정수 번호 등)으로 잡는 경우가 많습니다.
프로시저를 SELECT getDept_emp(10);으로 부르면 어떻게 되나요?
에러가 납니다(ERROR 1305, FUNCTION … does not exist). SELECT 안에서는 값을 돌려주는 함수만 찾기 때문이에요. 프로시저는 CALL getDept_emp(10);으로 실행합니다.
다음 장으로
이것으로 SQL 과정이 끝났어요. 다음 부(웹 개발)에서는 지금까지 HeidiSQL 창에 직접 치던 이 SQL을 자바 프로그램이 대신 보내도록(JDBC) 만듭니다. 게시판의 글 목록이 결국 21장의 SELECT … ORDER BY, 글쓰기가 24장의 INSERT라는 걸 확인하게 될 거예요.
주소창에 주소를 치면 브라우저 → Tomcat → 자바 코드 → HTML 응답 순서로 일이 일어난다는 것을 말할 수 있다.
Web Server와 WAS가 각각 무엇을 하는지, Tomcat은 어디에 속하는지 설명할 수 있다.
다이나믹 웹 프로젝트의 src/main/java · webapp · WEB-INF · WEB-INF/lib · web.xml이 각각 무엇인지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
자바 수업에서는 main 메서드를 내가 직접 실행했고, SQL 수업에서는 HeidiSQL 창에 쿼리를 직접 쳤어요. 둘 다 내 컴퓨터에서 나 혼자, 내가 버튼을 누를 때만 돌아가는 프로그램이었고, 결과도 콘솔 글자나 결과 표로만 봤죠. 웹은 다릅니다. 프로그램(서버)이 켜진 채로 기다리다가, 누군가 브라우저로 주소를 요청할 때마다 코드가 실행되어 HTML 화면을 돌려줘요. 01 프로젝트의 목표인 "브라우저에서 버튼 → 자바 → DB → 화면" 한 바퀴를 따라가려면, 먼저 그 일이 벌어지는 무대(Tomcat)와 무대의 구조(프로젝트 폴더)부터 알아야 합니다.
개념
① 요청과 응답 — 주소 하나를 뜯어 보기
웹은 편지 주고받기와 같아요. 브라우저가 "이 주소의 내용을 주세요"라는 요청(request)을 보내면, 서버가 HTML을 담은 응답(response)을 돌려보냅니다. 이 주고받기 약속이 HTTP예요. 수업 주소 http://localhost:8080/01_userboard_sepa/userList.jsp를 뜯어 보면 localhost는 내 컴퓨터, 8080은 Tomcat이 기다리는 포트 번호, /01_userboard_sepa는 컨텍스트 경로(Tomcat에 올라간 웹 앱 하나의 이름, 보통 프로젝트 이름), /userList.jsp는 그 앱 안에서 요청한 자원입니다.
중요한 점 하나: 브라우저가 받는 것은 완성된 HTML뿐이에요. JSP 안의 자바 코드는 서버에서 실행되고 끝나기 때문에, 브라우저의 "페이지 소스 보기"에는 나오지 않습니다.
② Web Server와 WAS — 진열대 점원과 주방
Web Server는 이미 만들어진 파일(HTML·CSS·이미지)을 그대로 건네주는 진열대 점원이에요. WAS(Web Application Server)는 요청이 올 때마다 프로그램을 실행해 결과를 그 자리에서 만들어 주는 주방입니다. 회원 목록처럼 DB 내용에 따라 매번 달라지는 화면은 주방이 있어야 만들 수 있죠.
수업에서 쓰는 Tomcat 10.1은 Servlet·JSP를 실행하는 서블릿 컨테이너이면서, HTTP 요청과 정적 파일도 직접 처리합니다. 그래서 따로 웹 서버 없이 Tomcat 하나로 실습해요. Tomcat 10.1은 Servlet 6.0 / JSP 3.1 규격을 따르고, 자바 패키지 이름이 jakarta.servlet입니다(Tomcat 9까지는 javax.servlet). JSP 파일은 Tomcat이 처음 요청받을 때 자바 클래스(서블릿)로 번역·컴파일해 두었다가 실행해요. 그래서 JSP를 고친 뒤 첫 요청은 조금 느립니다.
③ 다이나믹 웹 프로젝트 구조 — 공개 구역과 비공개 구역
Eclipse의 Dynamic Web Project는 정해진 폴더 약속을 따릅니다("Dynamic"은 요청마다 내용이 바뀌는 동적 페이지를 만든다는 뜻). src/main/java에는 자바 소스(DTO·DAO)를 두고, 빌드하면 .class가 WEB-INF/classes로 들어가요. src/main/webapp은 웹에 공개되는 루트라서 여기 둔 index.jsp는 주소로 바로 열립니다.
WEB-INF는 webapp 안에 있지만 브라우저가 직접 요청할 수 없는 구역이에요(서블릿 규격이 막아 줍니다). 그래서 설정 파일 web.xml(배포 서술자)과 라이브러리 폴더 lib이 여기 있어요. WEB-INF/lib에 넣은 jar(예: MariaDB 드라이버)는 Tomcat이 자동으로 클래스패스에 넣어 줘서 따로 설정할 필요가 없습니다.
⚠️ 흔한 오해 바로잡기
"Tomcat = WAS의 완전체"라고 외우면 안 됩니다. Tomcat은 Jakarta EE의 일부(Servlet·JSP·EL 등)를 구현한 서블릿 컨테이너예요. 또 인터넷 예제에 import javax.servlet.*이 있으면 Tomcat 9 이하 기준이라 Tomcat 10.1에서는 그대로 컴파일되지 않습니다. jakarta.servlet.*로 바꿔야 해요.
예제 — 수업 코드로 확인하기
주소에 파일 이름이 없으면 무엇이 열리나 — web.xml과 index.jsp
이 두 파일은 프로젝트를 만들자마자 "주소를 치면 무엇이 열리는가"를 확인하려고 본 것입니다. web.xml은 Eclipse가 프로젝트를 만들 때 자동으로 생성한 배포 서술자이고, index.jsp는 회원·구매 두 갈래로 가는 첫 화면이에요.
👀 관찰 포인트 — http://localhost:8080/01_userboard_sepa/처럼 파일 이름 없이 들어오면 Tomcat이 welcome-file 목록을 위에서부터 찾아 처음으로 존재하는 파일을 보여 줍니다. index.html은 없으니 index.jsp가 열려요. 링크 href="userList.jsp"에 앞부분 주소가 없는 건 상대 경로라서 — 지금 주소(/01_userboard_sepa/)를 기준으로 찾아갑니다. 브라우저에서 페이지 소스를 열면 첫 줄의 <%@ page %>는 사라지고 HTML만 남아 있어요.
8080과 3306 — 서버가 두 개다
UserDao의 접속 정보 몇 줄은 Tomcat 안의 자바가 또 다른 서버(MariaDB)에 접속한다는 것을 보여 줍니다. 비밀번호 줄에 남긴 주석은 WEB-INF가 왜 비공개 구역인지와 이어져요.
JAVAUserDao.java — DB 접속 정보 (getAllUser 안)
String url = "jdbc:mariadb://localhost:3306/hk"; // 내 컴퓨터(localhost)의 3306 포트에 있는 'hk' 데이터베이스
String user = System.getenv().getOrDefault("STUDY_DB_USER", "study");
// 공개 저장소에 올리면서 실제 값을 뺐다. 내 환경의 비밀번호를 넣고 쓸 것.
// 원래는 소스에 직접 적지 않고 WEB-INF 안의 설정 파일로 빼는 것이 맞다.
String password = System.getenv("STUDY_DB_PASSWORD");
👀 관찰 포인트 — 브라우저는 8080(Tomcat)에, Tomcat 안의 자바는 3306(MariaDB)에 접속합니다. 브라우저는 DB와 직접 이야기하지 않아요. 그리고 비밀번호가 든 설정 파일을 webapp 바로 아래 두면 주소만 알면 누구나 내려받을 수 있지만, WEB-INF 안에 두면 브라우저가 열 수 없습니다. 06 프로젝트부터는 접속 정보를 실제로 설정 파일(db.properties)로 빼고(→ 33장), 화면 JSP까지 WEB-INF/views 안에 숨겨요.
핵심 정리
웹 = 요청과 응답. 자바 코드는 서버에서 실행되고, 브라우저는 결과 HTML만 받는다.
정적 파일을 건네는 건 Web Server, 요청마다 프로그램을 실행하는 건 WAS. Tomcat은 둘 다 할 수 있는 서블릿 컨테이너(10.1 = jakarta.*).
JDBC 6단계(드라이버 → 연결 → 준비 → 실행 → 결과 → 닫기)를 순서대로 말하고, 실패하면 몇 단계 문제인지 짐작할 수 있다.
? 자리에 값을 채우는 PreparedStatement를 쓰고, executeQuery와 executeUpdate를 구별할 수 있다.
DTO(담는 상자)와 DAO(DB 출입 창구)를 왜 나누는지 설명할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
SQL 수업에서는 HeidiSQL에 SELECT를 쳐서 결과 표를 눈으로 봤어요. 하지만 웹에서는 손님이 버튼을 누를 때마다 자바가 대신 쿼리를 보내야 하고, 돌아온 결과 표를 자바 객체로 바꿔야 화면에 넘길 수 있습니다. 26장에서 본 "Tomcat 안의 자바 → 3306 포트의 MariaDB" 화살표가 바로 이번 장의 주제예요. 01 프로젝트는 화면을 붙이기 전에 UserMain(콘솔 실행 클래스)으로 이 연결부터 확인했습니다.
개념
① JDBC 6단계 — 전화 걸기와 같다
JDBC는 자바가 DB와 대화하는 표준 방법이에요. 전화로 치면 ① 드라이버 로딩(MariaDB 말을 통역할 드라이버 준비: Class.forName("org.mariadb.jdbc.Driver")) → ② 연결(전화 걸기: DriverManager.getConnection(url, user, password) → Connection) → ③ 쿼리 준비(할 말 적기: conn.prepareStatement(sql)) → ④ 실행 → ⑤ 결과 받기(ResultSet을 한 줄씩 읽어 객체에 담기) → ⑥ 닫기(끊기: 연 순서의 반대로 rs → psmt → conn). 닫지 않으면 DB 연결이 계속 쌓여 결국 새 연결을 못 얻게 돼요.
참고로 JDBC 4.0부터는 드라이버 jar만 클래스패스에 있으면 자동으로 등록되어 ①을 생략해도 동작합니다. 수업 코드는 단계를 눈으로 확인하려고 일부러 적어 둔 거예요.
② PreparedStatement와 ? — 빈칸 양식에 값 채우기
SQL에 값을 +로 이어 붙이지 않고 ?로 빈칸만 남긴 뒤, psmt.setString(1, 값)처럼 1번부터 채웁니다. 값이 SQL 문법으로 해석되지 않으니 따옴표 실수가 없고, 입력값으로 SQL을 조작하는 SQL 인젝션도 막아요.
실행 메서드는 두 가지예요. 조회(SELECT)는 executeQuery() → 결과 표(ResultSet). 등록·수정·삭제는 executeUpdate() → 영향받은 행 수(int). 그래서 DAO의 등록·수정·삭제 메서드는 그 수가 0보다 크면 성공으로 봅니다.
③ DTO와 DAO — 상자와 창구를 나눈다
DTO(Data Transfer Object)는 테이블 한 행을 담는 상자예요. userDto는 USERTBL의 컬럼 8개를 private 필드로 갖고 getter/setter로만 드나들게 합니다(캡슐화). DAO(Data Access Object)는 DB 출입을 전담하는 창구예요. UserDao에 getAllUser · getUser · insertUser · updateUser · deleteUser(CRUD 5종)를 모아 두면, 다른 코드는 dao.getAllUser()만 부르고 SQL이 어떻게 생겼는지 몰라도 됩니다. 프로젝트 이름의 sepa가 이 separation(분리)이에요.
④ 자원 닫기 — finally와 try-with-resources
getAllUser()는 finally에서 직접 close()를 부르고, insertUser()부터는 try ( … ) 괄호 안에 자원을 선언하는 try-with-resources를 써요. 괄호 안의 자원은 블록이 끝날 때(예외가 나도) 자바가 자동으로 닫아 줍니다. 같은 클래스 안에서 두 방식을 비교할 수 있게 섞어 둔 거예요.
⚠️ 수업 코드에서 조심할 곳
① DAO의 catch가 e.printStackTrace()만 하고 빈 목록이나 false를 돌려줘서, 화면에서는 "DB 접속 실패"와 "회원 0명"이 똑같이 보입니다. 05 무렵 실제로 이것 때문에 원인 찾기가 오래 걸렸어요(→ 조용히 실패하는 코드). ② INSERT INTO USERTBL VALUES(?, …)처럼 컬럼 목록을 생략하면 테이블 컬럼 구성이 바뀌는 순간 깨집니다. INSERT INTO USERTBL(USERID, NAME, …) VALUES(…)처럼 적는 게 안전해요. ③ 클래스 이름 userDto는 자바 관례(클래스 이름은 대문자로 시작)에 어긋납니다. 동작에는 문제없지만 02부터는 HkDto처럼 씁니다.
예제 — 수업 코드로 확인하기
회원 목록 — 6단계를 한 줄씩 출력해 보기
getAllUser()는 JDBC 6단계를 눈으로 확인하려고 단계마다 println을 넣은 실습입니다. 실패하면 몇 단계에서 멈췄는지가 콘솔에 바로 보여요. (원본의 긴 설명 주석은 줄이고, 단계 표시 주석을 달았습니다.)
JAVAUserDao.java — 생성자 + getAllUser()
public UserDao() {
try {
Class.forName("org.mariadb.jdbc.Driver"); // [1단계] 드라이버 로딩
System.out.println("1단계: 드라이버 로딩 성공 (MariaDB 통역사 준비 완료)");
} catch (ClassNotFoundException e) {
System.out.println("1단계: 드라이버 로딩 실패 (라이브러리 jar 파일이 누락되었는지 확인하세요)");
e.printStackTrace();
}
}
public List<userDto> getAllUser() {
List<userDto> list = new ArrayList<>();
// …(url · user · password 생략 — 26장 예제)
String sql = " SELECT "
+ " USERID, NAME, BIRTHYEAR, ADDR, MOBILE1, MOBILE2, HEIGHT, "
+ " MDATE FROM USERTBL "
+ " ORDER BY MDATE DESC ";
Connection conn = null;
PreparedStatement psmt = null;
ResultSet rs = null;
try {
conn = DriverManager.getConnection(url, user, password); // [2단계] 연결
System.out.println("2단계: DB연결 성공");
psmt = conn.prepareStatement(sql); // [3단계] 쿼리 준비
System.out.println("3단계: 쿼리준비 성공");
rs = psmt.executeQuery(); // [4단계] 실행(SELECT)
System.out.println("4단계: 쿼리실행 성공");
while (rs.next()) { // [5단계] 한 줄씩 DTO에 담기
userDto dto = new userDto();
dto.setUserId(rs.getString(1)); // 1번째 열: USERID (문자열)
dto.setName(rs.getString(2)); // 2번째 열: NAME (문자열)
// …(3~8번째 열 생략)
list.add(dto);
}
System.out.println("5단계: 쿼리결과 받기 성공 (총 " + list.size() + "명 포장 완료)");
} catch (SQLException e) {
System.out.println("SQL 실행 중 문제가 발생했습니다.");
e.printStackTrace();
} finally {
try { // [6단계] 연 순서의 반대로 닫기
if (rs != null) rs.close();
if (psmt != null) psmt.close();
if (conn != null) conn.close();
System.out.println("6단계: DB 연결 안전하게 닫기 완료");
} catch (SQLException e) { /* …(생략) */ }
}
return list;
}
👀 관찰 포인트 — UserMain을 Java Application으로 실행하면 1~6단계가 차례로 찍히고 회원 목록이 나옵니다. 2단계에서 멈추면 접속 정보·DB 기동 문제, 4단계에서 멈추면 SQL 문제예요. rs는 처음에 첫 행 앞을 가리키고 있어서 rs.next()로 한 칸 내려가야 읽을 수 있고, rs.getString(1)의 1은 SELECT에 적은 컬럼 순서입니다. SELECT 순서를 바꾸면 이 번호들도 같이 바꿔야 해요.
회원 등록 — ? 채우기와 자동 닫기
insertUser()는 PreparedStatement의 ? 바인딩과 try-with-resources를 보여 주려고 만든 메서드예요. DTO 상자 하나를 받아 그 안의 값을 빈칸에 옮깁니다.
JAVAUserDao.java — insertUser()
public boolean insertUser(userDto dto) {
int count = 0; // DB에서 영향을 받은 행(줄)의 개수를 셀 변수
// …(url · user · password 생략)
String sql = " INSERT INTO USERTBL VALUES(?,?,?,?,?,?,?,SYSDATE()) ";
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
psmt.setString(1, dto.getUserId()); // 1번째 ?: 회원 아이디
psmt.setString(2, dto.getName()); // 2번째 ?: 이름
psmt.setInt(3, dto.getBirthYear()); // 3번째 ?: 출생년도
// …(4~7번째 ? 생략)
count = psmt.executeUpdate(); // 등록·수정·삭제는 executeUpdate → 바뀐 행 수
} catch (SQLException e) {
System.out.println("회원 등록 실패 (아이디 중복 등)");
e.printStackTrace();
}
return count > 0 ? true : false;
}
👀 관찰 포인트 — close()가 한 줄도 없지만 괄호 안의 conn·psmt는 자동으로 닫혀요(대신 "6단계" 출력도 없습니다). 한 줄이 들어가면 count는 1. 이미 있는 아이디를 넣으면 기본키 위반으로 SQLException → false가 돌아옵니다. 마지막 줄 count > 0 ? true : false는 그냥 count > 0과 같아요(08에서는 그렇게 씁니다).
JSP의 <%@ %> · <% %> · <%= %>를 구별해 DAO 결과를 표로 찍을 수 있다.
폼·링크로 보낸 값을 request.getParameter()로 꺼내고 숫자로 바꿀 수 있다.
처리 뒤 sendRedirect나 location.href로 다른 화면으로 보내고, POST로 온 한글을 깨지지 않게 받을 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
27장의 DAO는 콘솔(UserMain)에서만 확인했어요. 결과가 println 글자로 나올 뿐이고, 사용자가 값을 입력할 방법도 없었죠. 이번 장에서는 그 DAO 위에 JSP로 화면을 붙여 "목록 보기 → 이름 클릭 → 상세·수정 → 저장 → 다시 목록"을 브라우저에서 한 바퀴 돌립니다. 01 프로젝트가 처음으로 "웹 프로그램"이 되는 순간이에요.
개념
① JSP 태그 세 가지 — 설정 · 실행 · 출력
JSP는 HTML 안에 자바를 끼워 넣은 파일이에요. <%@ page import="…" %>는 지시자(이 페이지의 설정: import, 인코딩), <% … %>는 스크립틀릿(자바 코드를 조용히 실행), <%= … %>는 표현식(값을 화면에 출력, 세미콜론 없음)입니다. request · response · out · session처럼 선언 없이 바로 쓰는 내장 객체가 있는데, 26장에서 본 것처럼 Tomcat이 JSP를 서블릿으로 번역할 때 미리 만들어 두기 때문이에요.
② request.getParameter — 화면에서 온 값은 전부 문자열
폼의 <input name="height">나 링크의 ?userid=KBS로 보낸 값은 request.getParameter("이름")으로 꺼냅니다. 열쇠는 input의 name이고, 돌아오는 값은 숫자처럼 보여도 항상 String(그런 이름이 없으면 null)이에요. 그래서 키는 Integer.parseInt(sHeight)로 바꿔야 하고, 빈칸이나 글자가 오면 NumberFormatException이 납니다.
링크로 보내는 GET은 값이 주소창에 보이고, method="post" 폼의 POST는 요청 본문에 담겨 가요. 화면엔 안 보여도 같이 보내야 하는 값(수정할 회원의 아이디)은 <input type="hidden">에 넣습니다.
③ 처리 페이지와 화면 이동 — sendRedirect
01에는 화면 없이 일만 하는 처리 JSP(userInsert.jsp · userUpdate.jsp · userDel.jsp)가 있어요. DAO를 부른 뒤 response.sendRedirect("userList.jsp")로 브라우저에게 "이 주소로 다시 요청하세요"라고 답합니다. 브라우저가 새로 요청하니 주소창도 바뀌어요. 알림창이 필요하면 응답에 <script>alert(…); location.href="…";</script>를 찍습니다 — 이것도 결국 브라우저가 새 주소를 요청하게 만드는 방법이에요.
주의: sendRedirect는 아래 자바 코드를 멈추지 않아요. 그래서 userDetail.jsp는 response.sendRedirect("error.jsp"); 바로 다음에 return;을 붙였습니다.
④ 한글 인코딩 — 값을 꺼내기 전에
POST 본문의 한글은 서버가 어떤 문자 규칙으로 읽을지 알아야 깨지지 않아요. 그래서 첫 getParameter보다 먼저request.setCharacterEncoding("UTF-8")을 부릅니다. 값을 한 번이라도 꺼낸 뒤에 부르면 효과가 없어요. 파일 맨 위의 pageEncoding·contentType은 JSP 파일과 응답의 인코딩이라 이것과는 별개입니다.
⚠️ 수업 코드에서 조심할 곳
userUpdate.jsp에는 request.setCharacterEncoding("UTF-8")이 있지만 userInsert.jsp에는 없습니다. 등록 폼의 이름 칸은 한글이 들어가는 칸이라, 서버 설정에 기대지 말고 같은 줄을 맨 앞에 넣어 두는 것이 안전해요. 이렇게 JSP마다 같은 줄을 빠뜨리지 않고 챙기는 일 자체가 불편이라, 30장에서 Filter 하나로 모든 요청에 한 번에 걸게 됩니다.
예제 — 수업 코드로 확인하기
회원 목록 화면 — JSP가 DAO를 직접 부른다
이 파일은 JSP 태그 세 가지와 "JSP 하나가 데이터 준비도 하고 화면도 그리는" MVC1 방식을 보여 주려고 만든 화면입니다.
👀 관찰 포인트 — for의 여는 중괄호와 닫는 중괄호가 서로 다른 <% %> 조각에 있고, 그 사이의 HTML <tr>이 회원 수만큼 반복됩니다. 이름 링크는 userDetail.jsp?userid=KBS처럼 쿼리스트링으로 아이디를 넘기고, 상세 화면은 그걸 getParameter("userid")로 받아요. 이 파일 하나가 DAO 호출·반복·HTML을 다 하고 있다는 점을 기억하세요 — 29장부터 이걸 나눕니다.
회원 수정 처리 — 화면 없는 JSP
이 파일은 인코딩 → 파라미터 받기 → 형변환 → DAO 호출 → 결과에 따라 이동이라는 처리 페이지의 순서를 보여 주려고 만든 것입니다. (원본의 긴 단락 주석은 [1]~[3] 표시로 줄였습니다.)
JSPuserUpdate.jsp — 수정 처리
<%
request.setCharacterEncoding("UTF-8"); // [1] 한글: 값을 꺼내기 전에
String userId = request.getParameter("userid"); // [2] 전부 String으로 온다
String addr = request.getParameter("addr");
String mobile1 = request.getParameter("mobile1");
String mobile2 = request.getParameter("mobile2");
String sHeight = request.getParameter("height"); // 문자로 넘어온 키 (예: "178")
int height = Integer.parseInt(sHeight);
UserDao dao = new UserDao(); // [3] DAO에 맡기기
boolean isS = dao.updateUser(new userDto(userId, addr, mobile1, mobile2, height));
if (isS) {
%>
<script type="text/javascript">
alert("회원정보를 수정했습니다.!!");
location.href="userDetail.jsp?userid=<%=userId%>";
</script>
<%
} else{
response.sendRedirect("error.jsp");
}
%>
👀 관찰 포인트 — 성공하면 자바스크립트가 알림을 띄우고 상세 화면으로 이동하고, 실패하면 서버가 sendRedirect로 error.jsp를 시킵니다. 둘 다 결국 브라우저가 새 주소를 다시 요청하는 방식이에요. userid는 상세 화면의 <input type="hidden" name="userid">에서 왔습니다 — 화면엔 안 보여도 폼과 함께 전송돼요. new userDto(userId, addr, …)는 수정에 필요한 다섯 값만 담으려고 오버로딩해 둔 생성자입니다.
핵심 정리
<%@ %> 설정, <% %> 실행, <%= %> 출력.
getParameter("name")은 항상 String(없으면 null). 숫자는 parseInt.
POST 한글은 setCharacterEncoding("UTF-8")을 값을 꺼내기 전에.
sendRedirect = 브라우저에게 새 요청을 시킴(주소창이 바뀜). 뒤 코드는 계속 실행되므로 필요하면 return.
01은 JSP 하나가 처리와 화면을 모두 하는 MVC1 구조.
확인 문제상세 화면에서 키 칸을 비우고 "회원수정"을 누르면 어떻게 될까요?
getParameter("height")가 빈 문자열 ""을 돌려주고, Integer.parseInt("")에서 NumberFormatException이 나 500 에러 화면이 됩니다. 값 검사가 필요한 자리예요.
<%= dto.getName(); %>처럼 표현식에 세미콜론을 붙이면?
표현식은 번역될 때 출력문의 괄호 안에 그대로 들어가는 "값"이라, 세미콜론이 있으면 번역된 자바 코드가 문법 오류가 되어 컴파일 에러(500)가 납니다.
userDetail.jsp에서 sendRedirect("error.jsp") 다음의 return;을 지우고 없는 회원을 열면?
이동 지시를 해 놓고도 아래 코드가 계속 실행되어, HTML을 그리다 dto.getUserId()에서 NullPointerException이 납니다.
다음 장으로
01은 기능마다 처리 JSP가 하나씩 늘고, JSP마다 new UserDao()를 부르고, DAO마다 접속 코드가 반복돼요. 29장(02)에서 이 반복을 상속과 컨트롤러로 정리합니다.
forward와 redirect의 차이, request scope에 담은 값이 언제까지 살아 있는지 말할 수 있다.
여러 글 삭제를 batch와 commit/rollback으로 한 묶음 처리하는 코드를 읽을 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
01에서 두 가지가 불편했어요. ① DAO 메서드마다 url·user·password와 연결 코드를 똑같이 반복해서, 비밀번호를 바꾸려면 10곳을 고쳐야 했고 ② 기능마다 처리 JSP가 하나씩 늘고 JSP마다 new UserDao()를 불러, 요청 처리와 화면이 여기저기 흩어져 있었죠. 02는 새 게시판(hkboard)을 만들면서 ①은 상속으로, ②는 요청을 한 창구로 모으는 컨트롤러로 정리합니다.
개념
① 상속으로 중복 없애기 — DataBase 부모 클래스
JDBC 1단계(드라이버 로딩)와 2단계(연결)는 어느 DAO에서나 똑같아요. 그래서 이 둘만 DataBase 클래스에 쓰고 HkDao extends DataBase로 물려받으면, DAO는 getConnection() 한 줄로 연결을 얻습니다. 비밀번호가 나오는 곳도 1곳으로 줄었어요. 자바 수업에서 배운 상속이 웹에서 처음 쓰인 장면입니다. 드라이버 로딩은 부모 생성자에 있어서, new HkDao() 때 부모 생성자가 먼저 실행되면서 함께 돌아요.
② 컨트롤러 — 요청을 받는 창구 하나
01은 "기능 하나 = 처리 JSP 하나"였어요. 02는 boardController.jsp하나가 모든 요청을 받고, ?command=boardlist처럼 넘어온 command 값을 보고 무슨 일을 할지 고릅니다. 코드 주석의 순서가 곧 컨트롤러의 일이에요: command 받기 → DAO 만들기 → 요청 분기 → 파라미터 받기 → DAO 실행 → scope에 담기 → 페이지 이동. 화면 JSP(boardlist.jsp)는 이제 DAO를 모르고, 컨트롤러가 담아 준 값만 꺼내 그립니다. 다만 컨트롤러가 아직 JSP라서 이 구조를 MVC1이라 불러요.
③ forward와 redirect, 그리고 scope
forward(pageContext.forward("boardlist.jsp"))는 서버 안에서 같은 요청을 다른 JSP에 넘기는 것 — 주소창은 그대로이고, request.setAttribute("list", list)로 담은 값이 그대로 전달돼요. redirect(response.sendRedirect(…))는 브라우저에게 새 요청을 시키는 것 — 주소창이 바뀌고, 이전 request에 담은 값은 사라집니다. 그래서 목록·상세처럼 "보여 주기"는 forward, 등록·수정·삭제 뒤에는 redirect로 목록 컨트롤러를 다시 불러요(새로고침해도 같은 글이 또 등록되지 않게).
값을 담아 두는 범위(scope)는 네 가지예요: page(이 JSP 안), request(이번 요청 — forward까지), session(사용자 한 명, 브라우저를 닫거나 만료될 때까지), application(웹 앱 전체). 필요한 가장 좁은 범위에 둡니다.
④ 여러 글 삭제 — batch와 트랜잭션
목록의 체크박스가 모두 name="seq"라서 request.getParameterValues("seq")로 String 배열을 받아요. 같은 DELETE를 ?만 바꿔 여러 번 실행하므로 addBatch()로 쌓았다가 executeBatch()로 한 번에 보냅니다. JDBC 연결은 기본이 auto commit(쿼리마다 바로 확정)이라, 묶음으로 다루려면 conn.setAutoCommit(false)로 끄고 끝에 commit(), 오류가 나면 rollback()합니다. 이것이 트랜잭션 — "전부 되거나 전부 취소"예요.
⚠️ 바로잡기
① MariaDB(HeidiSQL 포함)도 기본이 autocommit이에요. HeidiSQL에서 START TRANSACTION 없이 DELETE를 실행하면 바로 확정되어 ROLLBACK으로 되돌릴 수 없습니다. 자바에서 setAutoCommit(false)를 하는 이유와 같아요. ② mulDel()은 commit()을 먼저 한 뒤에 결과 배열을 검사합니다. 그래서 검사에서 실패로 판정돼 error.jsp로 가더라도 DB는 이미 확정된 상태예요. "하나라도 실패면 전부 취소"를 지키려면 검사를 commit() 앞에 두고, 실패면 rollback()하는 순서가 맞습니다.
예제 — 수업 코드로 확인하기
접속 코드는 부모에게 — DataBase와 HkDao
이 두 클래스는 상속으로 중복을 없애는 것을 보여 주려고 만든 코드입니다. 27장의 UserDao와 비교해 보세요.
JAVAdatasource/DataBase.java — 1·2단계 전담 부모
// JDBC 1단계와 2단계(DB 연결 통로 만들기)를 전담하는 부모 클래스입니다.
public class DataBase {
public DataBase() { // [1단계: DB 드라이버 로딩]
try {
Class.forName("org.mariadb.jdbc.Driver");
System.out.println("1단계: 드라이버 로딩 성공 (MariaDB 통역사 준비 완료)");
} catch (ClassNotFoundException e) { /* …(생략) */ }
}
public Connection getConnection() throws SQLException { // [2단계: 연결 통로 개설]
Connection conn = null;
String url = "jdbc:mariadb://localhost:3306/hk";
String user = System.getenv().getOrDefault("STUDY_DB_USER", "study");
String password = System.getenv("STUDY_DB_PASSWORD");
conn = DriverManager.getConnection(url, user, password);
return conn; // 연결된 통로 객체를 호출한 곳(DAO)으로 전달
}
}
JAVAdao/HkDao.java — 물려받아 쓰기
public class HkDao extends DataBase{
public List<HkDto> getAllList(){
List<HkDto> list = new ArrayList<>();
String sql = "SELECT SEQ, ID, TITLE, CONTENT, REGDATE FROM HKBOARD ORDER BY REGDATE DESC ";
try(Connection conn = getConnection(); // 2단계: 부모 클래스에서 연결 통로(conn)를 얻어옴
PreparedStatement psmt = conn.prepareStatement(sql); // 3단계: DB에 보낼 쿼리문 준비
){
try(ResultSet rs = psmt.executeQuery()){
while(rs.next()) {
HkDto dto = new HkDto();
dto.setSeq(rs.getInt(1)); // SEQ 컬럼의 정수값
// …(2~5번째 열 생략)
list.add(dto);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
👀 관찰 포인트 — HkDao 어디에도 url·비밀번호가 없어요. getConnection()을 자기 메서드처럼 부를 수 있는 건 부모에게 물려받았기 때문입니다. 그리고 이 SELECT에는 WHERE가 없어서 글 전부를 가져온다는 점도 기억해 두세요 — 35장 페이지 나누기의 출발점이에요.
창구 하나에서 command로 나누기 — boardController.jsp
이 파일은 컨트롤러의 7단계와 forward / redirect를 언제 쓰는지를 보여 주려고 만든 첫 컨트롤러입니다.
JSPboardController.jsp — 목록 · 등록 분기
<%
//1단계: command값 받기 -> 어떤 요청인지 확인하기 위한 값을 받는다
String command = request.getParameter("command");
if (command == null) command = "";
//2단계: DAO 객체 생성
HkDao dao = new HkDao();
//3단계: 요청분기(요청확인하기)
if(command.equalsIgnoreCase("boardlist")){//글목록요청확인
//4단계: 파라미터 받기 생략
//5단계: dao메서드 실행
List<HkDto>list=dao.getAllList();
//6단계:Scope객체에 담기
request.setAttribute("list", list);
//7단계: 페이지 이동
pageContext.forward("boardlist.jsp");
}else if(command.equalsIgnoreCase("boardInsert")){
String id = request.getParameter("id");
String title = request.getParameter("title");
String content = request.getParameter("content");
boolean isS=dao.insertBoard(new HkDto(id,title,content));
if(isS){
//그냥 boardlist.jsp페이지로 가면 안되고,
//반드시 컨트롤러를 거쳐서 가야 한다 --> list객체가 필요하기 때문
response.sendRedirect("boardController.jsp?command=boardlist");
}else{
response.sendRedirect("error.jsp");
}
}
// …(boardinsertform · boardDetail · boardUpdate · boardDelete · muldel 분기 생략)
%>
👀 관찰 포인트 — 목록은 setAttribute → forward: 같은 요청이라 list가 살아서 boardlist.jsp에 도착해요. 등록 뒤에는 boardlist.jsp가 아니라 목록 컨트롤러 주소로 redirect합니다. 새 요청에는 list가 없으니, 컨트롤러가 DAO를 다시 불러 최신 목록을 담아야 하거든요. getAttribute가 Object를 돌려줘서 (List<HkDto>) 형변환이 필요한 것도 보세요 — 32장에서 EL이 이 줄을 없애 줍니다.
여러 글 삭제를 한 묶음으로 — HkDao.mulDel()
이 메서드는 JDBC batch와 트랜잭션(commit/rollback)을 처음 보여 주려고 만든 실습입니다.
JAVAdao/HkDao.java — mulDel()
public boolean mulDel(String[] seqs) {
boolean isS = true;
int[] count= null;// 쿼리 실행 개수 저장
String sql = "DELETE FROM HKBOARD WHERE SEQ = ?";
try(Connection conn=getConnection();){
// 자동 commit 해제 --> rollback할 수 있음
conn.setAutoCommit(false);
try(PreparedStatement psmt=conn.prepareStatement(sql)){
for (int i = 0; i < seqs.length; i++) {
psmt.setString(1, seqs[i]);//쿼리 하나 완성
psmt.addBatch();//완성된 쿼리를 준비시켜줌
}
count = psmt.executeBatch();//batch 실행 후 결과는 배열반환
conn.commit();//DB에 반영
}catch (SQLException e) {
conn.rollback();// 오류가 나면 성공한 작업 되돌리기
e.printStackTrace();
}finally {
//원래 설정으로 되돌리기
conn.setAutoCommit(true);
}
// count[1,1,1,1,1] 각각의 쿼리가 성공하면 1
// …(count 배열 검사 생략)
} catch (SQLException e) {
e.printStackTrace();
isS=false;
}
return isS;
}
👀 관찰 포인트 — setAutoCommit(false) → (여러 DELETE) → commit(), 중간에 예외면 rollback(). 이 세 줄이 트랜잭션의 전부예요. 36장에서 스프링은 이 세 줄을 @Transactional 한 줄로 대신합니다. 화면 쪽은 체크박스 <input type="checkbox" name="seq">들과 <input type="hidden" name="command" value="muldel"/>가 든 POST 폼이고, 컨트롤러의 muldel 분기가 getParameterValues("seq")로 배열을 받아 이 메서드에 넘깁니다.
핵심 정리
공통 코드는 부모 클래스로(상속). 비밀번호 10곳 → 1곳.
컨트롤러 = 요청을 받아 무엇을 할지 고르는 창구. 02는 JSP 컨트롤러(MVC1).
보여 주기는 setAttribute + forward, 바꾸기 뒤에는 redirect로 목록 컨트롤러를 다시 부른다.
request scope 값은 forward해야 살아 있다. redirect하면 사라진다.
여러 쿼리를 한 묶음으로: setAutoCommit(false) → commit() / rollback().
확인 문제컨트롤러가 목록을 response.sendRedirect("boardlist.jsp")로 보내면 화면은 어떻게 될까요?
redirect는 새 요청이라 request.setAttribute("list", …)로 담은 값이 사라집니다. boardlist.jsp에서 list가 null이 되어 for문에서 NullPointerException이 나요.
글 등록 뒤에 forward로 목록을 보여 주면, 사용자가 새로고침(F5)을 눌렀을 때 무슨 일이 생길까요?
주소창에는 등록 요청이 그대로 남아 있어서, 새로고침이 그 등록 요청을 다시 보냅니다. 같은 글이 또 등록될 수 있어요. 그래서 바꾸는 요청 뒤에는 redirect합니다.
setAutoCommit(false) 줄을 빼면 rollback()은 무엇을 되돌릴 수 있을까요?
아무것도 되돌리지 못합니다. auto commit 상태에서는 DELETE 하나하나가 실행되는 즉시 확정되기 때문이에요.
로그인한 사용자의 아이디를 여러 화면에서 계속 쓰려면 어느 scope에 둬야 할까요?
session입니다. request는 요청 하나가 끝나면 사라지고, application은 모든 사용자가 공유하니까요.
다음 장으로
창구는 하나로 모였지만 컨트롤러가 아직 JSP라서, 자바 if문 수십 줄과 HTML 껍데기가 한 파일에 섞여 있어요. 30장에서 자바 클래스가 직접 요청을 받는 Servlet과 모든 요청 앞의 공통 처리(Filter)를 작게 익히고, 31장에서 이 컨트롤러를 옮깁니다.
HttpServlet을 상속하고 @WebServlet으로 주소를 연결해 GET·POST 요청을 처리할 수 있다.
서블릿 생명주기(생성 → init → service(doGet/doPost) → destroy)와 "객체가 하나뿐"이라는 말의 뜻을 설명할 수 있다.
ServletConfig · ServletContext · Session의 범위를 구별할 수 있다.
Filter로 모든 요청 앞뒤에 공통 처리(인코딩)를 끼울 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
29장(02)에서 요청 창구를 하나로 모았지만, 그 창구가 JSP라서 자바 분기 코드와 HTML 껍데기(<html><body>)가 한 파일에 섞여 있었어요. 또 02 컨트롤러에는 setCharacterEncoding이 없어서, 한글을 받으려면 여전히 파일마다 그 줄을 챙겨야 했죠. "요청 처리는 자바 클래스가, 화면은 JSP가" 맡으려면 자바로 요청을 받는 방법(Servlet)이, 한글처럼 모든 요청에 공통인 일은 한 곳(Filter)이 필요합니다. 게시판이 섞이면 헷갈리니 03은 DB도 없이 이 둘만 연습해요.
개념
① Servlet — 요청을 받는 자바 클래스
HttpServlet을 상속한 클래스에 @WebServlet("/HelloServlet.do")를 붙이면, 그 주소로 온 요청을 Tomcat이 이 클래스에 넘깁니다. GET이면 doGet(), POST면 doPost()가 불려요(정확히는 service()가 요청 방식을 보고 나눠 부릅니다). 같은 연결을 web.xml의 <servlet> · <servlet-mapping>으로 적을 수도 있는데, 03의 web.xml에 그 방식이 주석으로 남아 있어요.
서블릿에서 화면을 만들려면 out.print("<h1>…</h1>")처럼 HTML을 문자열로 찍어야 해서 아주 불편합니다. 그래서 요청 처리는 서블릿, 화면은 JSP로 나누게 돼요. 사실 JSP도 Tomcat이 서블릿으로 번역한 것이니, 둘은 같은 기술의 두 얼굴입니다.
② 생명주기 — 한 번 채용되어 계속 일하는 직원
Tomcat은 그 서블릿에 첫 요청이 올 때(load-on-startup을 주면 서버 시작 때) 객체를 딱 하나 만들고 init()을 1번 부릅니다. 이후 요청마다 service() → doGet()/doPost()가 실행되고, 서버를 끄거나 앱을 다시 배포할 때 destroy()가 1번 불려요. 여러 사용자의 요청이 같은 객체를 동시에 쓰므로, 요청마다 다른 값(파라미터 등)을 필드에 저장하면 안 됩니다. 지역변수에 두세요.
③ 세 가지 보관함 — 직원 수첩 · 가게 공지판 · 손님 사물함
ServletConfig는 서블릿 하나만의 설정(@WebInitParam으로 준 초기값)을 읽는 수첩, ServletContext는 웹 앱 전체가 공유하는 공지판(JSP의 application, web.xml의 <context-param>), HttpSession은 사용자 한 명마다 따로 생기는 사물함이에요. 세션은 브라우저 쿠키에 든 세션 ID(Tomcat은 JSESSIONID)로 누구 것인지 구별합니다. 29장의 scope 네 가지 중 application · session이 바로 이것이에요.
④ Filter — 건물 입구의 공통 검사대
@WebFilter("/*")를 붙인 필터는 요청이 서블릿·JSP에 닿기 전에 먼저 실행됩니다. doFilter() 안에서 chain.doFilter(request, response)앞에 쓴 코드는 요청이 들어갈 때, 뒤에 쓴 코드는 응답이 나올 때 실행돼요. 여기서 request.setCharacterEncoding(…)을 해 두면 JSP·서블릿마다 인코딩 줄을 쓸 필요가 없어집니다. chain.doFilter를 부르지 않으면 요청이 그 자리에서 멈춰 서블릿까지 가지 못해요.
⚠️ 수업 코드 바로잡기
① init(ServletConfig config)를 오버라이드하면서 super.init(config)를 빠뜨렸어요. 부모(GenericServlet)의 init(config)는 config를 저장한 뒤 인자 없는 init()을 부르는데, 그 일을 통째로 덮어써서 ⓐ 같은 클래스의 init()("init():최초 한번 실행")이 불리지 않고 ⓑ 나중에 getServletConfig()가 null이 됩니다. 첫 줄에 super.init(config);를 넣는 것이 맞아요. ② destroy() 주석의 "요청이 더이상 없으면 자동으로 소멸"은 부정확합니다. 요청이 잠시 없다고 사라지지 않고, Tomcat이 서블릿을 내릴 때(서버 종료·앱 재배포) 한 번 불려요. ③ EncodeFilter는 HttpFilter의 두 doFilter를 모두 오버라이드했지만 실제로 불리는 건 ServletRequest를 받는 쪽뿐입니다. HttpServletRequest를 받는 쪽은 부모의 doFilter가 넘겨줄 때만 불리는데, 그 부모 메서드를 덮어썼기 때문이에요.
예제 — 수업 코드로 확인하기
HelloServlet — 언제 무엇이 실행되나
이 클래스는 서블릿 생명주기와 세 가지 보관함을 콘솔 출력으로 관찰하려고 만든 실습입니다. 메서드마다 println을 넣어 실행 순서를 눈으로 봐요.
JAVAHelloServlet.java
//url-mapping 어노테이션 방식: tomcat7.0부터 지원
@WebServlet(
urlPatterns = {"/HelloServlet.do"},
initParams = {
@WebInitParam(name="name",value="한경닷컴")
}
)
public class HelloServlet extends HttpServlet{
//init(): 서블릿 객체가 생성될때 최초 한번 실행되는 메서드
@Override
public void init() throws ServletException {
System.out.println("init():최초 한번 실행");
}
@Override
public void init(ServletConfig config) throws ServletException {
//서블릿에 정의된 초기값 가져오기
String name=config.getInitParameter("name");
System.out.println("서블릿 초기값:"+name);
//ServletContext객체 얻어오기(JSP: application객체)
ServletContext application=config.getServletContext();
application.setAttribute("id", "hk");//객체 저장
System.out.println((String)application.getAttribute("id"));
//web.xml에 정의하고 가져올 경우
System.out.println((String)application.getInitParameter("driver"));
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse respose) throws ServletException, IOException {
System.out.println("요청주소:"+request.getRequestURL());
String param = request.getParameter("param");
PrintWriter out=respose.getWriter();
out.print("<h1 style='color:blue;'>서블릿개념</h1>");
out.print("<h2>서블릿에서 받은 파라미터:"+param+"</h2>");
// …(생략)
HttpSession session = request.getSession();
session.setAttribute("id", "hk");
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req, resp);
}
@Override
public void destroy() {
System.out.println("요청이 더이상 없으면 자동으로 서블릿 객체를 소멸시킨다.");
}
}
👀 관찰 포인트 — index.jsp의 링크 HelloServlet.do?param=servlet을 눌러 보세요. 첫 요청에서만 "서블릿 초기값:한경닷컴" · "hk" · "org.mariadb.jdbc.Driver"(web.xml의 <context-param>)가 찍히고, 그 뒤로는 몇 번을 눌러도 "요청주소:…"만 늘어납니다. 객체가 하나라는 증거예요. "init():최초 한번 실행"은 위 바로잡기 ①의 이유로 찍히지 않습니다. POST로 보내도 doPost가 doGet에 넘겨서 같은 결과가 나와요.
EncodeFilter — 모든 요청 앞뒤에 끼어들기
이 필터는 요청 앞·응답 뒤의 공통 처리와 인코딩을 한 곳에서 하는 법을 보여 주려고 만든 것입니다. 04·05는 이 클래스를 거의 그대로 가져가 씁니다.
JAVAEncodeFilter.java
@WebFilter(
urlPatterns = {"/*","/a","/b","/c"},//여러 패턴 정의 가능
initParams = {
@WebInitParam(name="encoding",value="utf-8")
}
)
public class EncodeFilter extends HttpFilter{
private String encode;
@Override
public void init(FilterConfig config) throws ServletException {
encode=config.getInitParameter("encoding");
}
// …(protected doFilter(HttpServletRequest, …) 오버라이드 생략 — 바로잡기 ③)
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("요청할때 실행할 코드");
//인코딩처리하는 코드
request.setCharacterEncoding(encode);
response.setContentType("text/html;charset="+encode);
//자식타입으로 형변환하기 --> HttpSevletRequest가 됨
HttpServletRequest httpReq=(HttpServletRequest)request;
System.out.println("요청URI(filter):"
+httpReq.getRequestURI());
//chain.doFilter 코드 실행 전 위치에 있는 코드--> 요청할때 실행될 코드
chain.doFilter(request, response);
//응답할때 실행될 코드
System.out.println("응답할때 실행할 코드");
}
}
👀 관찰 포인트 — 콘솔에서 "요청할때 실행할 코드"와 "응답할때 실행할 코드" 사이에 서블릿의 "요청주소:…"가 끼어 있는 걸 확인하세요. 필터가 서블릿 실행을 감싸고 있다는 뜻이에요. /*는 모든 주소라서 index.jsp를 열 때도 필터 출력이 찍힙니다. ServletRequest는 HTTP가 아닌 요청까지 다룰 수 있게 만든 상위 타입이라, URI 같은 HTTP 정보를 읽으려면 HttpServletRequest로 형변환해요.
핵심 정리
Servlet = HttpServlet 상속 + @WebServlet 주소. GET → doGet, POST → doPost.
객체는 하나, init·destroy는 1번, 요청마다 service. 요청값을 필드에 두지 않는다.
Config(서블릿 하나) / Context(앱 전체) / Session(사용자 한 명).
Filter: chain.doFilter 앞은 요청 때, 뒤는 응답 때. 인코딩 같은 공통 일은 필터 하나로.
MVC2에서 Model · View · Controller가 각각 무엇을 맡는지, MVC1과 무엇이 다른지 말할 수 있다.
*.board 매핑과 getRequestURI() · getContextPath()로 요청을 구분하는 코드를 읽을 수 있다.
서블릿에서 RequestDispatcher.forward와 sendRedirect를 골라 쓸 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
30장에서 Servlet과 Filter를 따로 익혔으니, 이제 02의 boardController.jsp를 자바 클래스로 옮길 차례예요. 02의 불편은 ① 컨트롤러가 JSP라 if문과 HTML 껍데기가 섞여 있고 ② 모든 주소가 boardController.jsp?command=… 꼴이라 길고 ③ 인코딩을 JSP마다 챙겨야 했다는 것. 04는 기능은 02와 똑같은 게시판이고, 바뀐 건 "요청을 받는 곳" 하나뿐입니다.
개념
① MVC2 — 세 역할이 파일 단위로 나뉜다
Model은 데이터와 DB 접근(HkDao · HkDto), View는 보여 주기만 하는 JSP, Controller는 요청을 받아 무엇을 할지 정하는 서블릿(BoardController)이에요. MVC1(02)은 컨트롤러도 JSP였고, MVC2는 컨트롤러가 순수 자바 클래스입니다. 식당으로 치면 주문 받는 직원(Controller)과 주방(Model)과 접시에 담아 내는 일(View)이 각자 자리를 갖게 된 것이죠. 33장 이후의 스프링 MVC도 이 구조 그대로예요.
② 프런트 컨트롤러 — 주소가 곧 명령
@WebServlet("*.board")는 확장자 매핑이에요. boardlist.board, boardDetail.board처럼 .board로 끝나는 모든 요청이 이 서블릿 하나로 들어옵니다. 모든 요청이 한 창구로 모이니 이런 서블릿을 프런트 컨트롤러라고 불러요. 어떤 일인지는 command 파라미터 대신 주소 자체로 구별합니다: getRequestURI()가 /04_hkboard_MVC2/boardlist.board, getContextPath()가 /04_hkboard_MVC2이니, 앞부분 길이만큼 잘라 내면(substring) /boardlist.board가 남아요. 쿼리스트링(?seq=3)은 URI에 포함되지 않습니다.
③ 서블릿에서 화면 이동하기
서블릿에는 pageContext가 없어서, forward는 request.getRequestDispatcher("boardlist.jsp").forward(request, response)로 합니다. 04는 이걸 dispatch() 메서드로 묶었어요. 규칙은 29장과 같아요: 목록·상세는 setAttribute + forward, 등록·삭제 뒤에는 sendRedirect("boardlist.board"). 수정 성공은 알림창이 필요해서 jsForward()가 <script>alert(…); location.href=…</script>를 응답에 찍습니다. 한글은 30장의 EncodeFilter를 util 패키지로 가져와 한 곳에서 처리해요.
⚠️ 수업 코드에서 조심할 곳
모든 분기가 doGet() 안에 있고 doPost()는 doGet()을 부르기만 해서, 삭제 같은 "바꾸는" 요청도 GET 링크로 실행됩니다(boardDelete.board?seq=3을 주소창에 치기만 해도 삭제). 링크를 미리 열어 보는 프로그램이 따라가기만 해도 지워질 수 있어서, 데이터를 바꾸는 요청은 POST로만 받는 것이 안전해요. 08(35장)에서는 삭제를 method = RequestMethod.POST로만 받습니다. 또 doPost()의 setCharacterEncoding은 필터가 이미 했으니 중복이에요(해롭지는 않습니다).
예제 — 수업 코드로 확인하기
BoardController — 02의 컨트롤러를 자바로 옮기기
이 서블릿은 02의 7단계는 그대로 두고 "요청을 받는 곳"만 바꾸면 어떻게 되는지를 보여 주려고 만든 것입니다. 29장 예제 2와 한 줄씩 비교해 보세요.
JAVAcontroller/BoardController.java
@WebServlet("*.board")
public class BoardController extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI=request.getRequestURI();
String contextPath=request.getContextPath();
String pathInfo=request.getPathInfo();
System.out.println(requestURI+"\n"
+contextPath+"\n"
+pathInfo
);
//command값 구하기 : "/boardlist.board" 추출
String command=requestURI.substring(contextPath.length());
//2단계: DAO 객체 생성
HkDao dao=new HkDao();
//3단계: 요청분기(요청확인하기)
if(command.equalsIgnoreCase("/boardlist.board")){//글목록요청확인
List<HkDto>list=dao.getAllList();
request.setAttribute("list", list);
// response.sendRedirect("boardlist.jsp");// 객체 전달 못함 (X)
dispatch("boardlist.jsp", request, response);
}else if(command.equalsIgnoreCase("/boardInsert.board")){// 글추가하기
// …(id · title · content 파라미터 받기 생략)
boolean isS= dao.insertBoard(new HkDto(id, title, content));
if(isS){
response.sendRedirect("boardlist.board");
//테이블 수정 요청에 대한 응답은 forward를 사용하면 안된다
// --> 주소창에 url이 갱신되지 않아서 동일한 양식을 또 제출하게 된다
//dispatch("boardlist.board", request, response);
}else{
response.sendRedirect("error.jsp");
}
}
// …(boardInsertForm · boardDetail · boardUpdate · boardDelete · muldel 분기 생략)
}
// forward 기능 구현
public void dispatch(String url, HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.getRequestDispatcher(url).forward(request, response);
}
👀 관찰 포인트 — 목록을 요청하면 콘솔에 세 줄이 찍혀요: /04_hkboard_MVC2/boardlist.board, /04_hkboard_MVC2, 그리고 null. 확장자 매핑에서는 getPathInfo()가 null이라, URI에서 컨텍스트 경로를 잘라 내는 방법을 쓴 거예요. 02와 비교하면 HTML 껍데기와 <% %>가 사라졌고, pageContext.forward가 dispatch()로 바뀐 것 말고는 흐름이 같습니다.
화면 속 주소가 바뀌었다 — 02와 04의 boardlist.jsp
이 비교는 command 파라미터 → 주소(URI)로 바뀐 것이 View에 어떻게 드러나는지 보여 줍니다.
👀 관찰 포인트 — hidden command가 주석이 되고, 링크가 boardDetail.board?seq=…로 짧아졌어요. 그런데 화면 안의 <% for(HkDto dto:list){ %>와 <%=dto.getSeq()%>는 그대로 남아 있습니다. 컨트롤러는 자바로 빠졌지만 View에는 아직 자바가 있다 — 이것이 32장의 출발점이에요.
핵심 정리
MVC2: Controller = Servlet, Model = DAO·DTO, View = JSP.
*.board로 한 서블릿에 모으고, URI − contextPath로 요청을 구분한다(프런트 컨트롤러).
forward는 getRequestDispatcher(…).forward(…), 바꾸는 요청 뒤에는 sendRedirect.
공통 처리(인코딩)는 Filter. 데이터를 바꾸는 요청은 POST로.
확인 문제/04_hkboard_MVC2/boardDetail.board?seq=3을 요청하면 command 변수에는 무엇이 들어갈까요?
/boardDetail.board입니다. ?seq=3 같은 쿼리스트링은 getRequestURI()에 포함되지 않고, getParameter("seq")로 따로 꺼내요.
프로젝트 이름(컨텍스트 경로)을 바꿔서 배포해도 분기 코드가 동작할까요?
동작합니다. 잘라 낼 길이를 getContextPath()로 그때그때 구하기 때문이에요. "/04_hkboard_MVC2"를 코드에 직접 적었다면 깨졌을 거예요.
04까지 와서도 남아 있는 불편은 무엇일까요?
View(JSP)에 <% %> 스크립틀릿이 남아 있다는 것 — 형변환, import, 여러 조각으로 흩어진 for의 중괄호. 32장에서 EL·JSTL로 걷어 냅니다.
다음 장으로
32장에서는 04를 그대로 복사해 JSP만 EL·JSTL로 바꿉니다. 자바 클래스는 하나도 안 바꾸는데 화면은 훨씬 깔끔해지는 — "역할을 나눠 두면 한쪽만 바꿀 수 있다"의 증명이에요.
${이름}이 scope에서 값을 찾고, ${dto.title}이 getter를 부른다는 것을 설명할 수 있다.
<c:forEach>와 <c:choose> · empty로 반복과 조건을 태그로 쓸 수 있다.
JSTL을 쓰려면 무엇(jar 2개, taglib 선언)이 필요한지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
31장(04)에서 컨트롤러는 자바 클래스로 빠졌지만, JSP 안에는 아직 (List<HkDto>)request.getAttribute("list") 같은 형변환, <%@page import%>, 여러 조각으로 흩어진 for의 중괄호가 남아 있었어요. 화면 담당자가 읽기 어렵고, list가 null이면 NullPointerException, 값이 null이면 화면에 "null" 글자가 찍혔죠. 05는 04를 그대로 복사해 JSP만 바꿉니다. Controller · DAO · DTO · Filter는 손대지 않아서(README 기록상 컴파일 결과가 바이트 단위로 같음), 04와 05의 차이가 곧 이번에 배운 것이에요.
개념
① EL — ${ }로 scope에서 꺼내기
EL(Expression Language)의 ${list}는 "list라는 이름의 값을 찾아 줘"라는 뜻이에요. page → request → session → application 순서로 찾고, 없으면 예외 대신 빈칸을 출력합니다. 컨트롤러가 request.setAttribute("list", …)로 붙인 이름이 화면과의 유일한 연결고리예요. ${requestScope.dto.seq}처럼 범위를 직접 적을 수도 있습니다.
${dto.title}은 필드를 직접 읽는 게 아니라 getTitle()을 호출해요. 그래서 DTO에 getter가 있어야 하고, 형변환·import는 필요 없습니다. empty 연산자는 null · 빈 문자열 · 빈 컬렉션을 모두 참으로 봐요.
② JSTL — 반복과 조건을 태그로
JSTL(JSP 표준 태그 라이브러리)의 core 태그를 쓰려면 파일 위에 <%@ taglib prefix="c" uri="jakarta.tags.core" %>를 선언해요. <c:forEach items="${list}" var="dto">는 list를 한 바퀴 돌며 각 원소를 dto라는 이름으로 꺼내 주고(list가 null이면 0번 돌고 지나감), <c:choose> 안의 <c:when test="…"> · <c:otherwise>는 if–else예요. <c:when>은 반드시 <c:choose>의 직속 자식이어야 합니다.
③ JSTL은 표준이지만 Tomcat에 들어 있지 않다
EL은 Tomcat이 바로 처리하지만 JSTL은 jar를 직접 넣어야 합니다: 규격(API) jakarta.servlet.jsp.jstl-api-3.0.1.jar와 구현 jakarta.servlet.jsp.jstl-3.0.1.jar두 개를 WEB-INF/lib에 넣어요. API만 넣으면 실행할 때 구현이 없어 실패합니다. 그리고 Tomcat 10부터의 javax → jakarta 변화가 taglib에도 와서, Tomcat 10.1과 함께 쓰는 JSTL 3.0의 core 태그 URI는 jakarta.tags.core예요.
⚠️ 05에 아직 남아 있는 것
"EL·JSTL로 바꿨다"와 "자바가 한 줄도 없다"는 다른 이야기예요. boardDetail.jsp 맨 위에는 <%@page import="…HkDto"%>와 HkDto dto = (HkDto)request.getAttribute("dto");가 아직 살아 있지만, 본문은 이미 ${requestScope.dto.…}로 바꿔서 이 변수는 쓰이지 않습니다. 지우면 이 파일에서도 자바가 완전히 빠져요. boardlist.jsp에 남은 import 두 줄도 마찬가지입니다.
예제 — 수업 코드로 확인하기
같은 표, 두 가지 방법 — 04와 05의 boardlist.jsp
이 비교는 EL·JSTL이 스크립틀릿의 무엇을 대신하는지를 보여 주려고 만든 실습입니다. 두 파일은 표 부분만 다르고 나머지는 같아요.
👀 관찰 포인트 — 05에는 getAttribute도, 형변환도 없어요. <%=dto.getTitle()%>가 ${dto.title}로 짧아졌고, 흩어진 중괄호 대신 <c:forEach>…</c:forEach> 짝이 눈에 보입니다. "작성된 글이 없습니다" 안내는 05에서 새로 생긴 기능인데, 04 방식이었다면 if 스크립틀릿이 또 필요했을 거예요. 컨트롤러가 담은 이름 "list"와 items="${list}"의 이름이 같아야 연결된다는 것도 확인하세요.
상세 화면 — 범위를 적는 EL과 남은 두 줄 (05 boardDetail.jsp)
이 화면은 폼의 value에 EL로 값을 채우는 법을 보여 주고, 동시에 아직 지우지 않은 스크립틀릿이 남아 있는 "공사 중" 상태를 보여 줍니다.
👀 관찰 포인트 — 위의 자바 변수 dto와 아래의 ${requestScope.dto…}는 서로 상관이 없어요. EL은 스크립틀릿의 지역변수를 보지 않고 scope(여기선 request)에서 "dto"라는 이름을 찾습니다. 그래서 위 스크립틀릿을 지워도 화면은 똑같이 나와요.
/home.do 요청이 web.xml → DispatcherServlet → @RequestMapping 메서드 → ViewResolver → JSP로 가는 길을 설명할 수 있다.
root-context.xml과 servlet-context.xml을 왜 나누는지, 각각 무엇을 담는지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
04·05까지 오면서 우리가 매번 직접 짠 것을 떠올려 보세요. jar를 받아 WEB-INF/lib에 복사하기, getRequestURI()를 잘라 if문으로 분기하기, "boardlist.jsp" 같은 forward 경로 문자열, 컨트롤러마다 new HkDao(), DAO 안의 DB 접속 정보, 직접 만든 EncodeFilter… 게시판이 바뀌어도 똑같이 반복되는 뼈대 코드예요. 이걸 대신해 주는 것이 스프링입니다. 06은 게시판 없이 "설정 파일이 무엇을 하는지"와 "주소 → 메서드 → 화면" 연결만 확인하는 틀(template) 프로젝트예요.
개념
① Maven — 라이브러리는 선언만
06부터는 Maven 프로젝트예요. pom.xml에 "spring-webmvc 6.1.13이 필요해"라고 적기만 하면 Maven이 jar를 내려받고, 그 jar가 필요로 하는 다른 jar까지 따라옵니다. <scope>provided</scope>는 "컴파일할 땐 필요하지만 Tomcat이 이미 갖고 있으니 war에 넣지 말라"는 뜻이에요. 서블릿·JSP API가 여기에 해당하고, 넣으면 Tomcat의 것과 충돌할 수 있습니다. 06의 pom에는 게시판에 쓸 MyBatis · spring-jdbc · DBCP · MariaDB 드라이버도 미리 들어 있어요.
② DispatcherServlet과 ViewResolver — 04의 BoardController를 스프링이 대신
web.xml에 스프링이 만든 서블릿 DispatcherServlet을 *.do로 매핑해요. 04의 *.board 서블릿 자리입니다. 요청이 오면 DispatcherServlet이 @RequestMapping(value="/home.do")가 붙은 메서드를 찾아 부르고(if문 대신), 메서드가 "home"이라는 이름만 돌려주면 ViewResolver가 /WEB-INF/views/ + home + .jsp로 경로를 완성해 forward합니다. JSP가 WEB-INF 안에 있으니 브라우저가 직접 열 수 없고, 반드시 컨트롤러를 거치게 돼요.
③ IoC와 두 개의 설정 — root와 servlet
@Controller는 "이 클래스를 객체로 만들어 관리해 줘"라는 표시이고, 실제로 찾아 등록하는 건 <context:component-scan base-package="com.hk.board"/>예요. 객체를 내가 new하지 않고 스프링(컨테이너)이 만들어 관리하는 것을 IoC(제어의 역전), 그렇게 만들어진 객체를 빈(bean)이라 합니다.
설정은 두 벌이에요. ContextLoaderListener가 읽는 root-context.xml에는 DB처럼 앱 전체가 쓰는 것(커넥션 풀 → SqlSessionFactory → SqlSessionTemplate)을, DispatcherServlet이 읽는 servlet-context.xml에는 컨트롤러·ViewResolver처럼 화면에 딸린 것을 둡니다. servlet 쪽(자식)은 root 쪽(부모)의 빈을 가져다 쓸 수 있지만 반대는 안 돼요. 한글은 직접 만든 필터 대신 스프링의 CharacterEncodingFilter를 web.xml에 등록합니다.
⚠️ 수업 코드에서 조심할 곳
① HomeController.main()은 "main"을 돌려주지만 /WEB-INF/views/main.jsp가 없어서 /main.do는 404예요. 이름만 돌려주는 방식은 편하지만, 이름과 파일이 어긋나도 컴파일할 때는 아무도 알려 주지 않습니다. ② root-context.xml의 PropertyPlaceholderConfigurer는 Spring 5.2부터 사용 중단(deprecated) 표시가 붙은 클래스예요. Spring 6.1에서도 동작은 하지만, 새로 쓴다면 <context:property-placeholder location="…"/>가 권장 방식입니다. ③ <mvc:resources mapping="/**"/>는 보통 /resources/**처럼 좁게 잡아요.
예제 — 수업 코드로 확인하기
web.xml — 요청이 스프링으로 들어가는 문
이 설정은 모든 *.do 요청을 DispatcherServlet에 맡기고, 설정 파일 두 개를 읽게 하는 것을 보여 주려고 만든 06의 출발점입니다.
XMLWEB-INF/web.xml
<!-- contextConfigLocation라는 이름을 리스너가 찾는다 -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/root-context.xml</param-value>
</context-param>
<!-- 리스너 설정: contextConfigLocation라는 이름의 값을 찾아서 읽어들인다. -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<!-- DispatcherServlet이라는 객체를 Servlet으로 등록 -->
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/appServlet/servlet-context.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>*.do</url-pattern>
</servlet-mapping>
<!-- …(CharacterEncodingFilter 등록 생략: encoding=UTF-8, forceEncoding=true, /*) -->
👀 관찰 포인트 — 30장에서 배운 것들이 다 보여요: <servlet> · <servlet-mapping>(서블릿 등록), <init-param>(ServletConfig 초기값), <context-param>(ServletContext 값), <load-on-startup>(첫 요청 전에 미리 생성). 스프링도 결국 서블릿 위에서 돌아가는 프로그램이라는 뜻입니다.
주소 → 메서드 → 화면 — servlet-context.xml과 HomeController
이 두 파일은 if문 분기와 forward 경로 문자열이 어디로 사라졌는지를 보여 주려고 만든 최소 예제입니다.
XMLspring/appServlet/servlet-context.xml
<!-- Spring MVC @Controller같은 어노테이션 방식으로 운영을 하자~ -->
<mvc:annotation-driven/>
<!-- 정적파일 경로(js, css, img)를 설정 -->
<mvc:resources location="/resources/" mapping="/**"></mvc:resources>
<!-- JSP 셋팅 : prefix/페이지이름suffix 경로를 만들어서 찾아줌 -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/" />
<property name="suffix" value=".jsp" />
</bean>
<!-- 객체를 xml에 등록하지 않아도 생성해서 사용할 수 있게 한다. -->
<context:component-scan base-package="com.hk.board" />
JAVAcontroller/HomeController.java
@Controller
public class HomeController {
@RequestMapping(value ="/home.do",method = RequestMethod.GET)
public String home() {
//페이지 이름만 작성한다
// --> WEB-INF/views + home + .jsp : ViewResolver가 해줌
return "home";
}
@RequestMapping(value ="/main.do",method = RequestMethod.GET)
public String main() {
return "main";
}
}
👀 관찰 포인트 — 04의 if(command.equalsIgnoreCase("/boardlist.board"))가 @RequestMapping(value="/home.do") 한 줄이 됐고, dispatch("boardlist.jsp", …)가 return "home";이 됐어요. new HomeController()는 어디에도 없습니다 — component-scan이 찾아 만들었어요.
DB 연결 사슬 — root-context.xml (9/23 추가)
이 설정은 DAO 안에 있던 DB 접속 정보를 밖으로 빼서 스프링이 관리하게 하는 것을 보여 주려고 06에 미리 깔아 둔 것입니다. 07(34장)이 이 위에서 MyBatis로 SQL을 실행해요.
<!-- …(db.properties를 읽는 PropertyPlaceholderConfigurer 생략) -->
<!-- datasource에 대한 관리를 하는 객체 등록 -->
<!-- autoCommit=false의 의미: 커밋시점을 프레임워크가 정한다. -->
<bean id="dataSource"
class="org.apache.commons.dbcp2.BasicDataSource">
<property name="driverClassName" value="${driver}"/>
<property name="url" value="${url}"/>
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
<property name="defaultAutoCommit" value="false"/>
</bean>
<!-- sqlSessionTemplate를 생성해주는 객체 등록 -->
<bean id="sqlSessionFactory"
class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/>
<property name="configLocation"
value="classpath:sqls/Configuration.xml"/>
</bean>
<!-- 쿼리를 실행할 객체 등록 -->
<bean id="sqlSessionTemplate"
class="org.mybatis.spring.SqlSessionTemplate">
<constructor-arg index="0" ref="sqlSessionFactory" />
</bean>
👀 관찰 포인트 — ref="dataSource"처럼 빈끼리 이름으로 연결돼요: 연결을 빌려주는 풀(DBCP) → MyBatis 공장 → 쿼리 실행기. ${url} 같은 값은 db.properties 파일에서 채워져서, 비밀번호가 자바 소스에서 완전히 사라졌습니다(26장에서 말한 "설정 파일로 빼기"). DBCP는 연결을 미리 만들어 두고 빌려 쓰고 돌려받는 커넥션 풀이라, 01처럼 요청마다 연결을 새로 열고 닫는 비용이 줄어요.
핵심 정리
jar 복사 → pom.xml 선언 / URI 자르기 + if → @RequestMapping / forward 경로 → return "이름" + ViewResolver / new → component-scan(IoC) / EncodeFilter → CharacterEncodingFilter.
요청 하나가 Controller → Service → DAO → Mapper(SQL)를 거쳐 화면까지 가는 길을 순서대로 말할 수 있다.
객체를 new로 만들지 않고 스프링이 넣어 주는 것(DI)이 무엇인지 설명할 수 있다.
MyBatis가 namespace + id로 SQL을 찾는다는 것을 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
33장(06 프로젝트)에서 스프링의 뼈대만 세웠어요. 주소 하나가 화면 하나로 가는 것까지만 확인했죠. 이번 장에서는 그 뼈대 위에 02·04·05에서 만든 것과 똑같은 게시판을 다시 올립니다. 기능은 같고 짜는 방식만 바뀌어서, "예전엔 내가 직접 하던 일을 이제 누가 대신하나"를 비교하기 좋습니다.
개념
① 계층 구조 — 일을 네 층으로 나눈다
식당으로 치면 Controller는 주문 받는 직원(어떤 요청인지 보고 누구에게 넘길지 정함), Service는 주방장(업무 규칙: "답글이면 두 가지 일을 한 번에"), DAO는 창고 담당(DB에 드나드는 일만), Mapper(XML)는 창고 열쇠 목록(실제 SQL)입니다. 05까지는 Controller가 DAO를 바로 불렀는데, 07부터 Service 층이 새로 생겼어요. 지금은 Service가 DAO를 그대로 부르기만 해서 쓸모없어 보이지만, 08에서 "여러 쿼리를 한 묶음으로(트랜잭션)" 처리할 때 이 층이 그 일을 맡습니다.
② DI(의존성 주입) — 필요한 객체를 밖에서 넣어 준다
04·05에서는 Controller 안에서 HkDao dao = new HkDao();로 직접 만들었어요. 07에서는 필드에 @Autowired만 붙이면 스프링이 미리 만들어 둔 객체(빈)를 찾아서 넣어 줍니다. 그리고 변수 타입을 HkService가 아니라 인터페이스 IHKService로 선언했어요. 그래야 나중에 구현을 바꿔도 Controller 코드는 그대로 둘 수 있습니다.
③ MyBatis — SQL은 XML에, 자바는 이름만 부른다
JDBC로 짜던 연결 → PreparedStatement → ResultSet → DTO에 옮겨 담기 → 닫기를 MyBatis가 대신해요. 우리는 XML에 SQL과 이름(id)만 적고, DAO에서 "namespace.id" 문자열로 부릅니다. 결과를 담을 타입(resultType="HkDto")만 알려 주면 컬럼 이름과 필드 이름이 같은 것끼리 자동으로 채워 줍니다.
예제 — 수업 코드로 확인하기
글 목록 요청 하나를 끝까지 따라가 보기
이 네 조각은 "계층이 어떻게 이어지는지"를 보여 주려고 만든 코드입니다. 각 파일이 딱 한 가지 일만 하는 데 주목하세요.
JAVA① BoardController.java — 요청 받기
@Controller
public class BoardController {
@Autowired
private IHKService hkService; // 스프링이 HkService 객체를 넣어 준다
@RequestMapping(value="/boardlist.do", method = RequestMethod.GET)
public String boardList(Model model) {
List<HkDto> list = hkService.getAllList(); // ② Service에 맡김
model.addAttribute("list", list); // 화면에 넘길 값
return "boardlist"; // → /WEB-INF/views/boardlist.jsp (forward)
}
JAVA② HkService.java — 업무 처리(지금은 전달만)
@Service
public class HkService implements IHKService {
@Autowired
private IHkDao hkDao;
@Override
public List<HkDto> getAllList() {
return hkDao.getAllList(); // ③ DAO에 맡김
}
JAVA③ HkDao.java — DB 출입
@Repository
public class HkDao implements IHkDao {
private String namespace = "com.hk.board.dao.";
@Autowired
private SqlSessionTemplate sqlSession;
@Override
public List<HkDto> getAllList() {
return sqlSession.selectList(namespace + "boardList"); // ④ "com.hk.board.dao.boardList"
}
XML④ BoardMapper.xml — 실제 SQL
<mapper namespace="com.hk.board.dao">
<select id="boardList" resultType="HkDto">
SELECT SEQ, ID, TITLE, CONTENT, REGDATE
FROM HKBOARD ORDER BY REGDATE DESC
</select>
👀 관찰 포인트 — ③의 namespace + "boardList"와 ④의 namespace·id가 글자 하나까지 같아야 연결됩니다. 한 글자라도 다르면 Mapped Statements collection does not contain value 에러가 나요. 그리고 어디에도 new가 없다는 점을 확인하세요. 객체는 전부 스프링이 만들어 넣었습니다.
⚠️ 수업 코드에서 조심할 곳
Spring 6.1부터는 @RequestParam에 이름을 생략하면(String seq만 쓰면) 컴파일 옵션 -parameters가 없을 때 예외가 납니다. 07의 여러 글 삭제처럼 @RequestParam("seq")로 이름을 꼭 적어 주세요.
핵심 정리
요청의 길: DispatcherServlet → Controller → Service → DAO → Mapper → DB, 결과는 거꾸로 올라와 Model에 담겨 JSP로 간다.
@Controller·@Service·@Repository를 붙인 클래스는 스프링이 객체로 만들어 두고, @Autowired 자리에 넣어 준다(DI).
MyBatis는 namespace.id로 SQL을 찾고, resultType에 맞춰 결과를 자동으로 담는다.
저장·수정·삭제 뒤에는 return "redirect:boardlist.do" — 새로고침해도 다시 저장되지 않게(PRG).
확인 문제05에서 Controller가 직접 하던 일 중 07에서 사라진 것 두 가지는?
① new HkDao()로 객체를 직접 만드는 일 → 스프링이 주입. ② URI를 잘라 어떤 요청인지 분기하는 일 → @RequestMapping이 메서드마다 주소를 연결.
DAO의 namespace 문자열을 "com.hk.board.daos."로 바꾸면 무슨 일이 생기나요?
Mapper의 namespace는 그대로 "com.hk.board.dao"라서 이름이 맞지 않아 SQL을 찾지 못하고 에러가 납니다. 자바 패키지 이름과는 상관없이 두 문자열만 같으면 됩니다.
Service가 DAO를 그대로 부르기만 하는데 왜 굳이 만들었을까요?
업무 규칙이 들어갈 자리를 미리 만든 것입니다. 08에서 답글을 달 때 "아래 글 밀기(UPDATE) + 답글 넣기(INSERT)"를 하나의 트랜잭션으로 묶는데, 그 묶음의 단위가 Service 메서드입니다.
다음 장으로
07로 "돌아가는 게시판"이 완성됐어요. 하지만 글이 1,000개면 한 화면에 다 나오고, 답글도 못 달아요. 35장에서는 08 프로젝트로 페이지 나누기와 답글을 만듭니다.
refer · step · depth 세 숫자로 답글이 원글 밑에 줄 서는 원리를 설명하고, 답글을 달 때 무엇이 바뀌는지 손으로 계산할 수 있다.
ROW_NUMBER()로 10개씩 자르는 SQL과 Paging 유틸이 계산하는 페이지 번호(시작·끝·이전·다음)를 읽을 수 있다.
조회수를 올린 뒤 redirect하는 이유, DELETE 대신 delflag를 쓰는 이유를 말할 수 있다.
왜 지금 이걸 배우나 — 앞 장과의 연결
34장(07)에서 스프링 + MyBatis 게시판이 돌아가게 됐어요. 하지만 실제로 쓰기엔 부족했죠. 07의 목록 SQL에는 WHERE도 LIMIT도 없어서 글이 1,000개면 한 화면에 1,000줄이 나오고, 답글을 달 수 없고, 지우면 DELETE로 영영 사라지고, 몇 명이 읽었는지(조회수)도 없고, 화면마다 같은 머리·꼬리를 복사해야 했어요. 08은 07을 복사해 패키지를 com.hk.ansboard로 바꾸고, 같은 Controller → Service → DAO 구조 위에 이 기능들을 올립니다.
개념
① refer · step · depth — 답글을 한 테이블에 줄 세우기
답글도 원글과 같은 answerboard 테이블에 들어가요. 그러면 "어느 글 묶음에, 몇 번째 줄로, 몇 칸 들여서" 보여 줄지를 숫자 세 개로 기록해야 합니다. refer는 원글 묶음 번호(새 글은 MAX(refer)+1), step은 묶음 안에서의 줄 순서(원글은 0), depth는 들여쓰기 칸 수(원글 0, 답글 1, 답글의 답글 2)예요. 목록은 ORDER BY refer DESC, step ASC — 새 묶음이 위로, 묶음 안에서는 step 순서대로 섭니다.
답글을 달 때는 ① 같은 refer에서 부모보다 step이 큰 글들을 한 칸씩 밀고(UPDATE step+1) ② 비워진 자리(부모 step+1)에 부모 depth+1로 INSERT 합니다. 줄 선 사람들 사이에 누가 끼어들 때 뒷사람들이 한 걸음씩 물러나는 것과 같아요.
② 페이지 나누기 — ROW_NUMBER와 Paging 유틸
SQL에서 ROW_NUMBER() OVER(ORDER BY refer DESC, step ASC)로 정렬된 순서대로 1, 2, 3… 번호(rn)를 붙이고, ceil(rn/10) = #{pnum}으로 pnum번째 10개만 가져와요(1~10은 1쪽, 11~20은 2쪽). 전체 쪽수는 ceil(count(*)/10)입니다.
아래 번호판은 Paging.pagingValue(전체쪽수, 현재쪽, 5)가 5개씩 묶어 계산해요: 8쪽이면 6~10이 보이고, "이전"은 5쪽, "다음"은 11쪽. 화면을 옮겨 다녀도 보던 쪽으로 돌아오도록 글쓰기·상세·수정·삭제의 모든 링크와 폼이 pnum을 계속 들고 다녀요.
③ 조회수와 논리 삭제
목록의 제목 링크에는 review=y가 붙어 있어요. 이 표시가 있을 때만 조회수를 +1 하고, 곧바로 review가 없는 상세 주소로 redirect 합니다. 그래서 상세 화면에서 새로고침(F5)을 눌러도 조회수가 또 오르지 않아요(대신 요청이 두 번 오가는 비용이 있습니다).
삭제는 DELETE 대신 UPDATE … SET delflag='Y' — 논리 삭제예요. 원글을 진짜로 지우면 그 아래 답글들의 줄이 어색해지니, 줄은 남겨 두고 목록에서 "---삭제된 글입니다.---"로 보여 줍니다. 체크한 여러 번호는 07과 같이 MyBatis <foreach>로 IN (…)을 만들어요.
④ 화면 조각 나누기 — jsp:include
모든 화면에 반복되는 메뉴(머리)와 저작권(꼬리)을 header.jsp · footer.jsp로 빼고 <jsp:include page="header.jsp"/>로 불러와요. <jsp:include>는 요청 때마다 그 JSP를 실행한 결과를 끼워 넣는 동적 include이고, <%@ include file="…" %>는 번역 단계에서 소스를 붙여 한 파일로 만드는 정적 include예요. 모양은 Bootstrap 클래스(table table-striped, btn btn-primary, pagination)로 입혔습니다.
⚠️ 수업 코드에서 조심할 곳
① 실패할 때의 return "error.jsp";는 뷰 이름으로 해석되어 ViewResolver가 /WEB-INF/views/error.jsp.jsp를 찾습니다. 게다가 08의 views에는 error.jsp가 없어요. views에 error.jsp를 두고 return "error";로 쓰는 것이 맞습니다. ② header.jsp · footer.jsp가 각각 <!DOCTYPE html><html>…</html>을 가진 완전한 문서라서, 목록 화면의 결과 HTML에는 <html>이 세 번 나옵니다. 브라우저가 너그럽게 처리해 보일 뿐이고, 조각 파일에는 <nav>…</nav>처럼 필요한 부분만 두는 게 맞아요.
이 SQL과 유틸은 "pnum쪽에 해당하는 10개만" 가져오고, 아래 번호판을 5개씩 묶어 계산하는 페이징을 보여 주려고 만든 것입니다. 07의 목록 SQL과 비교하면 바깥에 SELECT가 한 겹 더 생겼어요.
XMLsqls/BoardMapper.xml — 목록 · 쪽수
<select id="boardList" parameterType="Map" resultType="AnsDto" >
SELECT rn, seq, id, title, content, regdate, refer, step, depth, readcount, delflag
FROM (
SELECT ROW_NUMBER() OVER(ORDER BY refer DESC, step ASC) AS rn,
seq, id, title, content, regdate, refer, step, depth, readcount, delflag
FROM answerboard
) a
WHERE ceil(rn/10) = #{pnum}
</select>
<!-- page 개수 구하기 -->
<select id="getPcount" resultType="int">
select ceil(count(*)/10)
from answerboard
</select>
JAVAutil/Paging.java — pagingValue()
public static Map<String, Integer> pagingValue(int pcount,String pNum,int pageRange){
Map<String, Integer> map=new HashMap<String, Integer>();
int pNumber=Integer.parseInt(pNum);
//1234(5) 6789(10) : 페이지 번호를 받아 해당 페이지의 마지막 페이지 번호를 구함
int pageEndNum=((pNumber-1)/pageRange+1)==1?pageRange:((pNumber-1)/pageRange+1)*pageRange;
int prePageNum=pageEndNum-pageRange==0?1:pageEndNum-pageRange;
int nextPageNum=pageEndNum>=pcount?pcount:pageEndNum+1;
int startPage=pageEndNum-(pageRange-1);//현재페이지번호가 8일경우 10-(5-1)= 6
int endPage=pageEndNum>pcount?pcount:pageEndNum;
// …(map.put 네 줄 생략: prePageNum · nextPageNum · startPage · endPage)
return map;
}
👀 관찰 포인트 — 안쪽 SELECT가 전체 글에 번호를 매기고, 바깥 WHERE가 그중 한 쪽만 고릅니다. ROW_NUMBER()는 WHERE보다 나중에 계산되기 때문에 같은 단계의 WHERE에서 rn을 쓸 수 없어 한 겹 감싼 거예요. 글이 23개면 ceil(23/10) = 3쪽 — MariaDB의 /는 소수 나눗셈(2.3)이라 ceil로 올립니다. 반대로 자바의 (pNumber-1)/pageRange는 정수 나눗셈이라 소수점이 버려져요: 8쪽 → 7/5+1 = 2번째 묶음 → 끝 번호 10 → 시작 6, 이전 5, 다음 11. 첫 줄의 삼항 연산자는 두 갈래가 같은 값이라 ((pNumber-1)/pageRange+1)*pageRange 하나로 충분합니다. Controller는 이 계산을 직접 하지 않고 AnsService.getBoardListWithPaging(pnum)이 목록·쪽수·번호를 한 Map으로 모아 줘요(36장).
답글 끼워 넣기 — replyUpdate + replyInsert
이 두 쿼리는 refer · step · depth로 답글 자리를 만드는 방법을 보여 주려고 만든 것입니다. 부모 글의 seq 하나만 받고, 나머지는 서브쿼리로 부모의 값을 읽어 와요.
XMLsqls/BoardMapper.xml — 답글달기
<!-- 답글달기 -->
<update id="replyUpdate" parameterType="AnsDto">
<![CDATA[
UPDATE answerboard SET step = step+1
WHERE refer=(SELECT refer FROM answerboard WHERE seq=#{seq})
AND step > (SELECT step FROM answerboard WHERE seq=#{seq})
]]>
</update>
<insert id="replyInsert" parameterType="AnsDto">
INSERT INTO answerboard
VALUES(NULL,#{id},#{title},#{content},SYSDATE(),
(SELECT refer FROM answerboard WHERE seq=#{seq}),
(SELECT step FROM answerboard WHERE seq=#{seq})+1,
(SELECT depth FROM answerboard WHERE seq=#{seq})+1, 0, 'N')
</insert>
👀 관찰 포인트 — 원글 A(step 0) 밑에 답글 B(step 1, depth 1)가 있을 때 A에 새 답글 C를 달면: ① B의 step이 1 → 2로 밀리고 ② C가 step 1, depth 1로 들어가서 순서는 A → C → B(가장 최근 답글이 원글 바로 밑). 상세 화면의 답글 폼은 <input type="hidden" name="seq" value="${dto.seq}"/>로 부모의 seq를 보내고, 스프링이 그 값을 AnsDto의 seq에 담아 줍니다. <![CDATA[ … ]]>는 SQL 속 비교 기호가 XML 태그로 오해받지 않게 감싸는 표시예요. 그리고 이 두 쿼리는 둘 다 되거나 둘 다 안 되어야 합니다 → 36장.
조회수는 한 번만 — boardDetail()
이 메서드는 "값을 바꾸는 요청 뒤에는 redirect"(PRG) 원칙을 조회수에 적용한 예입니다.
JAVAcontroller/AnsController.java — boardDetail()
//글 상세보기
@RequestMapping(value = "/boardDetail.do",
method = RequestMethod.GET)
public String boardDetail(@RequestParam("seq")int seq,
@RequestParam(value="review",required = false)String review,
@RequestParam("pnum") String pnum,
Model model) {
// y값이 있는 경우가 글목록에서 요청된 경우
if(review!=null&&review.equals("y")) {
ansService.readCount(seq);//조회수 올리기
//한번요청에 2번 통신을 하게 되서 성능은 저하될 수 있음
return "redirect:boardDetail.do?seq="+seq+"&pnum="+pnum;
}else {
AnsDto dto=ansService.boardDetail(seq);
model.addAttribute("dto", dto);
model.addAttribute("pnum", pnum);
//객체 담아서 페이지로 이동하는 경우--> 페이지 이름만 써주면 됨
return "boardDetail";
}
}
👀 관찰 포인트 — 목록에서 누르면 …?seq=5&review=y&pnum=2 → 조회수 +1 → …?seq=5&pnum=2로 다시 요청 → 상세 화면. 주소창에 남는 건 review가 없는 주소라 새로고침해도 조회수가 그대로예요. required = false라서 review 없이 와도 에러 없이 null이 들어옵니다. redirect 주소에 pnum을 다시 붙이는 것도 보세요 — "글목록" 버튼이 보던 쪽으로 돌아가게 하려는 거예요.
@Transactional이 Service 메서드를 "전부 되거나 전부 취소"로 묶는 방법과, 그게 걸리려면 어떤 설정(root/servlet 스캔 나누기)이 필요한지 설명할 수 있다.
AOP의 Advice · Pointcut · JoinPoint를 aop-context.xml에서 찾아 읽을 수 있다.
System.out.println 대신 SLF4J 로거와 레벨을 쓰는 이유, 08의 log4j.xml이 읽히지 않는 이유를 말할 수 있다.
생성자 주입과 "Controller는 얇게"가 무엇인지 안다.
왜 지금 이걸 배우나 — 앞 장과의 연결
35장의 답글 저장은 UPDATE(아래 글 밀기) + INSERT(답글 넣기) 두 쿼리예요. 트랜잭션이 없으면 쿼리마다 바로 확정되므로, INSERT만 실패하면 step만 밀리고 답글은 없는 빈자리가 남습니다. 29장(02)에서는 setAutoCommit(false) → commit()/rollback()을 손으로 썼지만, 스프링 + MyBatis에서는 우리가 Connection을 직접 잡지 않아요. 또 01부터 써 온 System.out.println 확인 출력은 끄고 켤 수 없고, DAO 메서드마다 손으로 넣어야 했죠. 이 장은 "돌아가는 코드"를 "믿고 운영할 수 있는 코드"로 다듬는 도구들을 봅니다.
개념
① 트랜잭션 — @Transactional 한 줄
root-context.xml에 트랜잭션 매니저(DataSourceTransactionManager: 연결의 auto commit을 끄고, 끝에 commit/rollback 하는 일꾼)를 등록하고 <tx:annotation-driven/>을 켜면, Service 메서드에 @Transactional만 붙여도 그 메서드 전체가 한 묶음이 됩니다. 끝까지 성공하면 commit, 중간에 RuntimeException이 나면 rollback. MyBatis(SqlSessionTemplate)는 같은 DataSource의 그 연결을 함께 써서 두 쿼리가 같은 트랜잭션 안에 들어가요. Propagation.REQUIRED(기본값)는 "진행 중인 트랜잭션이 있으면 합류, 없으면 새로 시작"입니다. 묶음의 단위가 Service 메서드라는 것 — 07에서 쓸모없어 보이던 Service 층이 여기서 제 몫을 해요.
② 프록시와 AOP — 코드를 고치지 않고 앞뒤에 끼워 넣기
스프링은 @Transactional이 붙은 AnsService를 그대로 주지 않고, 그것을 감싼 대리 객체(프록시)를 Controller에 넣어요. 대리 객체가 메서드 앞에서 트랜잭션을 열고, 뒤에서 commit/rollback 합니다. 이렇게 여러 곳에 공통으로 필요한 일을 핵심 코드 밖에서 끼워 넣는 방식이 AOP예요. 용어: Advice(끼워 넣을 일 — LogExecute의 before · afterReturning · daoError), Pointcut(어디에 — execution(* com.hk.ansboard.dao.*Dao.*(..)): dao 패키지에서 이름이 Dao로 끝나는 클래스의 모든 메서드), JoinPoint(지금 끼어든 실행 지점의 정보 — 메서드 이름·인자·대상 객체), Aspect(Advice + Pointcut 묶음).
주의할 점 두 가지. 대리 객체를 거쳐야 걸리므로, 같은 클래스 안에서 this.boardReply()처럼 직접 부르면 트랜잭션이 안 걸려요. 그리고 tx:annotation-driven은 자기가 선언된 컨텍스트(root)의 빈에만 적용됩니다. servlet-context가 패키지 전체를 스캔하면 servlet 쪽에 트랜잭션 없는 AnsService가 하나 더 생기고 Controller가 그걸 받아 버려요. 그래서 08은 servlet은 controller만, root는 service · dao만 스캔하게 나눴습니다.
③ 로그 — SLF4J는 창구, 출력은 Logback
로거는 메시지마다 레벨(TRACE < DEBUG < INFO < WARN < ERROR)을 붙여서, 코드를 고치지 않고 설정만으로 "개발 땐 DEBUG까지, 운영에선 INFO부터"처럼 걸러 낼 수 있어요. 출력할 곳(콘솔·파일)과 모양도 설정으로 정합니다. 코드가 부르는 SLF4J(LoggerFactory.getLogger(AnsController.class))는 창구일 뿐이고, 실제 출력은 클래스패스에 있는 구현체 — 08에서는 Logback(logback-classic)이 해요. logger.debug("…:{}", 값)의 {}는 자리 표시라, 꺼진 레벨이면 문자열을 이어 붙이는 비용도 들지 않습니다.
④ 생성자 주입과 얇은 Controller
08의 Controller는 필드 @Autowired 대신 생성자로 Service를 받아요. 생성자가 하나뿐이면 @Autowired를 적지 않아도 스프링이 그 생성자로 넣어 줍니다(Spring 4.3부터). 필요한 객체가 없으면 Controller 자체가 만들어지지 않아 문제를 일찍 알 수 있고, 테스트에서 new AnsController(가짜서비스)처럼 스프링 없이 만들 수도 있어요. 그리고 "목록 + 쪽수 + 번호 계산" 조립은 업무 규칙이라 AnsService.getBoardListWithPaging()으로 옮겨서, Controller는 받은 Map을 Model에 담기만 합니다.
⚠️ 수업 코드 바로잡기
① 주석은 "slf4j(준비 작업), log4j(실제 출력 작업)"이라 하고 설정 파일도 log4j.xml(Log4j 1.x 형식)이지만, pom.xml에 들어 있는 구현체는 Logback이고 Log4j 라이브러리는 없습니다. Logback은 logback-test.xml → logback.xml을 찾다가 없으면 기본 설정(콘솔, 전체 DEBUG)으로 동작하고 log4j.xml은 읽지 않아요. 그래서 날짜별 로그 파일도 생기지 않습니다. 설정을 적용하려면 같은 내용을 src/main/resources/logback.xml로 옮겨야 해요. ② LogExecute의 LoggerFactory.getLogger(join.getTarget().getClass()+"")는 Class를 문자열로 바꾼 거라 로거 이름이 class com.hk.ansboard.dao.AnsDao처럼 "class "로 시작합니다. 패키지 이름으로 레벨을 정하는 설정에 걸리지 않으니 getClass()를 그대로 넘기세요(LogExecuteNoXML처럼). ③ rollback은 기본적으로 RuntimeException과 Error에만 일어나요. checked 예외(SQLException 등)는 commit 되므로 필요하면 rollbackFor = Exception.class를 적습니다. 다만 MyBatis-Spring은 SQL 오류를 스프링의 DataAccessException(RuntimeException의 하위)으로 바꿔 던지므로, DAO의 SQL 실패는 기본 설정으로도 rollback 돼요.
예제 — 수업 코드로 확인하기
답글 저장을 한 묶음으로 — 트랜잭션 설정과 boardReply()
이 코드는 두 쿼리를 하나의 트랜잭션으로 묶는 것과, 그게 실제로 걸렸는지 눈으로 확인하는 법을 보여 주려고 만든 실습입니다.
@Transactional(propagation = Propagation.REQUIRED)
public boolean boardReply(AnsDto dto) {
boolean txActive = TransactionSynchronizationManager
.isActualTransactionActive();
logger.info("트랜젝션 활성 상태:{}",txActive);
// ---> 답글 요청 ---> update작업, insert작업
// --> 한번 요청에 여러작업이 하나의 작업처럼 진행되게 처리
ansDao.replyUpdate(dto);//step 증가시키는 쿼리
//일부러 오류를 발생 : 트랜젝션 테스트를 위해
// if(true) {
// throw new RuntimeException("트랜젝션 롤백 테스트");
// }
int count=ansDao.replyInsert(dto);//답글 추가하는 쿼리
return count>0;
}
👀 관찰 포인트 — 로그의 "트랜젝션 활성 상태:true"가 트랜잭션이 걸렸다는 증거예요. 주석 처리된 throw를 풀면 UPDATE 뒤에 예외가 나서 rollback → step이 밀리지 않은 원래 상태로 남습니다. 02의 setAutoCommit(false) · commit() · rollback() 세 줄이 애너테이션 한 줄이 된 셈이에요. 짝이 되는 servlet-context.xml의 스캔은 <context:component-scan base-package="com.hk.ansboard.controller" />로 좁혀져 있습니다. 40일차까지처럼 com.hk.ansboard 전체를 스캔하게 되돌리면 같은 코드에서 false가 찍혀요. proxy-target-class="true"는 08의 Service가 인터페이스 없는 클래스라 클래스를 상속한 대리 객체를 만들라는 뜻입니다.
DAO는 그대로, 로그는 바깥에서 — aop-context.xml과 LogExecute
이 설정은 DAO 코드를 한 줄도 고치지 않고 모든 DAO 메서드에 로그를 붙이는 AOP를 보여 주려고 만든 것입니다. web.xml의 contextConfigLocation이 root-context.xml과 함께 이 파일을 읽어요.
// target 메서드가 실행되기 전에 수행될 기능을 정의
public void before(JoinPoint join) {
Logger logger=
LoggerFactory.getLogger(join.getTarget().getClass()+"");
logger.info("before실행(info):시작:{}",
join.getSignature().getName());
logger.debug("before실행(debug):시작:{}",join.toLongString());
}
// …(afterReturning · daoError 생략 — 같은 모양으로 로그를 남긴다)
👀 관찰 포인트 — AnsDao를 열어 보면 로그 코드가 한 줄도 없어요. 그런데 답글을 저장하면 replyUpdate와 replyInsert 앞뒤로 "before실행 … afterReturning실행"이 찍힙니다. INSERT가 실패하면 afterReturning 대신 daoError가 불려요. 포인트컷을 execution(* com.hk.ansboard.service..*(..))로 바꾸면 같은 Advice가 Service 메서드에 붙습니다 — 주석 처리된 LogExecuteNoXML이 애너테이션(@Aspect · @Before …)으로 그렇게 쓴 버전이에요.
얇은 Controller — 생성자 주입과 로거
이 코드는 생성자 주입, 조립을 Service에 넘긴 Controller, 그리고 로거 선언과 레벨을 한 번에 보여 줍니다.
JAVAcontroller/AnsController.java — 앞부분
@Controller
public class AnsController {
// @Autowired
private AnsService ansService;
public AnsController(AnsService ansService) {
this.ansService=ansService;
}
//log 출력을 위한 선언: slf4j(로그출력할 준비작업), log4j(실제 출력 작업)
private static final Logger logger=
LoggerFactory.getLogger(AnsController.class);
@RequestMapping(value = "/home.do",
method = RequestMethod.GET)
public String home() {
logger.info("HOME페이지로 이동");
logger.debug("Working Directory:{}", System.getProperty("user.dir"));
return "home";
}
@RequestMapping(value = "/boardList.do",
method = RequestMethod.GET)
public String boardList(Model model,
@RequestParam(value="pnum",
defaultValue = "1" //기본값 설정
) String pnum) {
// …(주석 처리된 예전 조립 코드 생략)
//위 코드 작업을 AnsService에서 처리하자
Map<String, Object>result=ansService.getBoardListWithPaging(pnum);
model.addAllAttributes(result);
model.addAttribute("pnum", pnum);//현재 페이지상태를 유지하기 위해 전달
return "boardList";
}
👀 관찰 포인트 — @Autowired가 주석인데도 ansService가 채워지는 건 생성자가 하나라서예요. 이 생성자가 받는 건 root 컨텍스트의 트랜잭션 대리 객체입니다. model.addAllAttributes(result)는 Map의 키(list · pCount · pMap)마다 addAttribute를 한 것과 같아서, JSP에서 ${pMap.startPage}처럼 꺼내요. defaultValue = "1" 덕분에 boardList.do만 쳐도 1쪽이 나옵니다. 주석의 "log4j(실제 출력 작업)"는 위 바로잡기 ①대로 실제로는 Logback이고, 설정이 안 읽혀 기본값(DEBUG)이라 logger.debug 줄도 콘솔에 찍혀요. 필드에 final까지 붙이면(08은 아직 안 붙임) 한 번 받은 Service가 바뀌지 않음을 보장할 수 있습니다.
핵심 정리
여러 쿼리가 한 업무면 Service 메서드에 @Transactional. 성공하면 commit, RuntimeException이면 rollback.
확인 문제@Transactional을 지운 상태에서 replyInsert가 실패하면 DB는 어떻게 될까요?
UPDATE는 이미 확정되어 같은 묶음의 아래 글들 step만 1씩 밀리고, 답글은 들어가지 않습니다. step 순서에 빈자리가 생겨요.
servlet-context.xml이 com.hk.ansboard 전체를 스캔하면 왜 트랜잭션이 안 걸릴까요?
servlet 컨텍스트에도 AnsService 빈이 따로 생기는데, 거기에는 tx:annotation-driven이 없어서 대리 객체가 아닌 그냥 객체입니다. Controller는 가까운(자기) 컨텍스트의 그 빈을 받으므로 "트랜젝션 활성 상태:false"가 돼요.
목록을 열 때(getBoardListWithPaging) LogExecute의 로그는 어느 메서드에 찍힐까요?
포인트컷이 dao 패키지의 *Dao 클래스라서, AnsService 메서드 자체에는 안 찍히고 그 안에서 부른 AnsDao.getAllList와 AnsDao.getPcount 앞뒤에만 찍힙니다.
운영 서버에서 logger.debug 메시지를 안 보이게 하려면 코드를 고쳐야 할까요?
아니요. 설정 파일(Logback이면 logback.xml)에서 해당 로거의 레벨을 INFO로 올리면 됩니다. 이게 println 대신 로거를 쓰는 가장 큰 이유예요.
다음 장으로
6부는 10/2(42일차)까지의 수업을 다뤘어요. 앞으로 JUnit 단위 테스트, 파일 업로드, 일정관리(캘린더) 게시판을 거쳐 Spring Boot로 넘어갑니다. Boot에서는 web.xml · root/servlet-context 같은 XML 설정 대부분이 사라지지만, 26장부터 쌓아 온 요청 → Controller → Service → DAO → DB 흐름과 트랜잭션·AOP의 원리는 그대로 가져갑니다.
수업에서 직접 작성한 실습 코드를 날짜별로 정리합니다. 아래 개념 카드들이 "교안 전 범위 예습"이라면, 이 탭은 "오늘 실제로 진도가 나간 부분"이라서 시험 범위를 가늠하는 기준이 됩니다. 각 항목에서 관련 개념 카드로 바로 이동할 수 있어요.
1일차
2026-08-03 · 명명법 · 메모리 영역 · 기본타입과 형 변환
HelloJavaVariableTest카멜/파스칼static다운캐스팅
한 줄 요약첫 실습에서 HelloJava로 명명법·main 메서드의 구성 요소·static과 stack 메모리·상수를, VariableTest로 기본타입 8종의 표현 범위와 다운캐스팅·연산 시 자동 승격을 직접 코드로 확인했다.
쉽게 말하면1일차는 "자바가 값을 어디에 어떻게 담는가"를 배운 날이에요. 이름 짓는 규칙(명명법) → 값을 담을 상자의 크기(기본타입) → 큰 상자의 물건을 작은 상자에 옮길 때 생기는 문제(다운캐스팅) 순서로 이어집니다. 이 세 가지가 이후 모든 자바 코드의 바닥에 깔립니다.
① HelloJava — 명명법과 메모리 영역
패키지 선언(hk.edu20260803.day01)이 파일의 최상단에 오고 폴더 구조와 일치해야 한다는 것부터 시작했습니다. 이어서 클래스명은 파스칼, 메서드·변수명은 카멜, 상수는 전부 대문자라는 명명법을 코드에 직접 적용했고, public static void main(String[] args)를 접근지정자 / static / 반환타입 / 메서드명 / 매개변수 다섯 조각으로 쪼개 읽었습니다. → ☕ 02. 명명법 · 식별자 · 주석
② static과 stack — 어디에 저장되는가
public static final int NUMBER = 10000;(상수)과 public int number = 10;(인스턴스 필드)을 나란히 두고 static이 붙으면 static 메모리(Method Area)에 저장된다는 것, main 메서드 안의 지역변수·매개변수는 stack에 저장된다는 것을 확인했습니다. 그리고 "static인 main 안에서는 static이 없는 메서드를 바로 호출할 수 없다"는 규칙을 testMethod()를 static으로 선언하는 것으로 실습했습니다. 진짜 이유는 "메모리에 없어서"가 아니라, non-static 메서드는 어떤 객체의 것인지(this)가 있어야 호출되는데 static 메서드 안에는 그 객체가 없기 때문입니다. → 🧱 05. static vs non-static과 메모리 3영역
③ VariableTest — 기본타입의 표현 범위
byte(1byte, -128 ~ 127) · short(2byte) · int(4byte) · long(8byte)의 크기를 직접 대입해 보며 확인했고, 리터럴 정수는 기본이 int이므로 long 범위의 값에는 L을 붙여야 한다는 것을 배웠습니다. 실수는 기본이 double이라 float에는 f를 붙여야 하고, 안 붙이면(float f = 15.77;) 경고가 아니라 컴파일 에러(incompatible types: possible lossy conversion from double to float)가 나서 실행 자체가 안 됩니다. → ☕ 04. 기본타입 8가지와 메모리
④ 다운캐스팅과 정수 연산의 자동 승격
큰 타입의 값을 작은 타입에 담을 때 (byte)처럼 강제 형변환(다운캐스팅)이 필요하고, 범위를 넘으면 원본 값이 손실된다는 것을 (byte) 100000과 (byte) 126을 비교해 확인했습니다. 그리고 이 날의 하이라이트 — byte + byte의 결과가 왜 int인가. 컴퓨터가 더 큰 값을 담기 위해 int로 자동 승격하기 때문이며, 그래서 byte b3 = (byte)(b1 + b2);처럼 다시 캐스팅해야 합니다. 반면 byte b4 = 10 + 20;은 리터럴끼리라 컴파일 시점에 30으로 확정되므로 오류가 나지 않습니다. → ☕ 06. 형 변환(캐스팅)과 타입 변환 총정리
JAVAHelloJava.java (수업 실습 원본)
package hk.edu20260803.day01; //파일의 폴더 구조(경로), 최상단에 위치
//명명법
//클래스명: 파스칼
public class HelloJava {
// main 메서드: java코드를 실행시켜줌
// public: 접근지정자
// static: 내장 메모리 (static 메모리에 저장된다)
// void: 반환값 없음
// main: 메서드명
// args: 매개변수
// 메서드명: 카멜방식 (첫글자 소문자, 뒤 글자마다 대문자)
// 변수명: 카멜방식 (첫글자 소문자, 뒤 글자마다 대문자)
// 상수명: 스네이크 방식(모두 대문자)
// 멤버필드: static을 붙이면 static메모리에 저장됨
// 상수선언: 대문자
public static final int NUMBER = 10000;
public int number = 10;
// 매개변수, 지역변수: main메서드 안의 괄호안에 있는 변수들은 stack메모리에 저장됨
public static void main(String[] args) { // static으로 메모리에 이미 올라가 있음. 그래서 다른 static이 없는 메서드를
// 여기에 넣어서 실행하려고 하면 안됨
System.out.println("Hello Java");
testMethod();
}
// 메서드 선언: 카멜
public static void testMethod() {
// 변수명: 카멜
boolean isS = true;
int i = 100;
i = 200;
final int TEST = 10; // final: 상수로 선언됨. 변경 불가능
System.out.println("메서드 실행결과: " + i);
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
Hello Java
메서드 실행결과: 200
선언만 하고 출력하지 않은 NUMBER(상수)와 number(인스턴스 필드)는 화면에 나오지 않습니다 — 메모리에 올라가 있는 것과 출력되는 것은 별개입니다. 둘째 줄이 200인 이유는 testMethod() 안에서 i를 100으로 초기화한 뒤 200으로 덮어썼기 때문입니다.
JAVAVariableTest.java (수업 실습 원본)
package hk.edu20260803.day01;
public class VariableTest {
public static void main(String[] args) {
// 기본 타입의 특징
// 1.정타입
// : 기본형은 int
byte b = 1;
b = 127; // byte 표현범위는 -128 ~ 127
b = -128;
short sh = 128; // 2byte 크기
int i = 100000; // 4byte 크기
long l = 1000000000000000000L; // 리터럴 정수는 기본 int 형으로 인식함 , 8byte 크기 (long타입 정수는 L을 붙여줘야함)
System.out.println("long타입 표현범위: " + l);
byte bb = (byte) i; // byte -> int 다운캐스팅 , 원본값 손실됨
int ii = 126;
byte bbb = (byte) ii; // int -> byte 다운캐스팅, 원본값이 손실되지 않음
System.out.println("바이트 타입 표현범위: " + bbb);
System.out.println("======================");
// 2.실수타입
// 기본형은 double(8byte)
double d = 15.7;
float f = 15.77f; // 8byte크기라 f 붙여줌 , 기본적으로 double로 인식해서 컴파일러가 "double 값을 float 변수에 담으면 데이터가 손실될 수
// 있다!"라고 경고 띄움
float ff = (float) (d + f); // 큰값을 작은 상자에 담는다 (down casting)
// 3. 다른 타입끼리 연산
int iii = (int) (i + d); // int+double -> double, 연산결과가 double이므로 강제형변환 필요
// 4.정수끼리 연산
byte b1 = 10;
byte b2 = 20;
// byte b3 = b1 + b2; // byte + byte 인데, 왜 오류가 나는가? = int타입으로 자동 캐스팅 하기 때문(컴퓨터가
// 더 큰 숫자를 담기위함)
// => 그래서 연산을 하기전 b1,b2 를 int 타입으로 변경해준 후 더해야한다.
byte b3 = (byte) (b1 + b2); // 연산의 결과값은 int로 반환되므로, 그 값을 byte 타입의 b3에 담기 위해서는 강제형변환이 필요
// 변수끼리 연산은 변하는 값이기 때문에 127을 벗어날 수 있다.
byte b4 = 10 + 20; // 리터럴 정수는 기본 int 타입으로 인식하기 때문에 오류가 나지 않는다.
System.out.println("바이트 타입 연산 결과: " + b4);
// 5.
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
long타입 표현범위: 1000000000000000000
바이트 타입 표현범위: 126
======================
바이트 타입 연산 결과: 30
126은 int 126을 byte로 다운캐스팅한 값 — byte 범위(-128~127) 안이라 손실이 없습니다. 바로 위에서 한 (byte) i(i = 100000)는 범위를 넘어 값이 깨지는 쪽인데 출력하지 않았으니, 궁금하면 System.out.println(bb);를 넣어 비교해 보세요. 마지막 30은 10 + 20이 리터럴이라 컴파일 시점에 계산돼 byte에 그대로 들어간 경우입니다.
큰 타입 → 작은 타입은 강제 형변환(다운캐스팅)이 필요하고, 범위를 넘으면 값이 손실된다.
byte + byte는 int로 자동 승격되므로 byte에 담으려면 다시 캐스팅해야 한다. 단 리터럴끼리(10 + 20)는 컴파일 시점에 확정되어 그냥 담긴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
byte b3 = b1 + b2; 는 컴파일 에러인데 byte b4 = 10 + 20; 은 왜 통과할까?
둘 다 더한 결과는 int지만, b1 + b2는 변수끼리의 연산이라 컴파일 시점에 값을 알 수 없어 byte 범위 안이라고 보장할 수 없으니 컴파일러가 대입을 거부합니다. 10 + 20은 컴파일 시점에 30으로 확정되는 상수식이고 byte 범위 안이라 허용됩니다. 그래서 byte b5 = 100 + 100;(200)은 상수식이어도 범위를 넘어 에러입니다 — 주석의 "리터럴은 int라서"는 이유가 아닙니다.
byte bb = (byte) i; 다음 줄에 System.out.println(bb); 를 넣으면(i = 100000) 무엇이 찍힐까?
-96입니다. int를 byte로 캐스팅하면 아래 8비트만 남기고 나머지를 버립니다. 100000을 256으로 나눈 나머지는 160이고, byte에서는 128 이상이 음수로 해석되어 160 - 256 = -96이 됩니다. 에러 없이 조용히 틀린 값이 나온다는 점이 범위를 넘는 다운캐스팅의 진짜 위험입니다.
testMethod()에서 static을 지우면 왜 main에서 바로 부를 수 없을까? 고치는 방법 두 가지는?
static이 없는 메서드는 어떤 객체의 메서드인지(this)가 있어야 호출되는데, static인 main 안에는 그 객체가 없기 때문입니다("메모리에 아직 없어서"가 아닙니다). 고치려면 ① testMethod()를 다시 static으로 두거나, ② new HelloJava().testMethod();처럼 객체를 만들어 그 객체로 부르면 됩니다.
💼 실무·코딩테스트에서는실무에서는 회원 번호·주문 금액처럼 int 범위(약 21억)를 넘을 수 있는 값은 처음부터 long으로 받습니다(DB의 BIGINT와 짝). 그리고 돈 계산에는 double·float 대신 BigDecimal을 씁니다 — 실수 타입은 0.1조차 정확히 담지 못해 오차가 쌓이기 때문입니다. 코딩테스트에서는 결과가 int를 넘는 문제가 단골 함정입니다 — x만큼 간격이 있는 n개의 숫자는 반환 타입이 long[]이고 곱셈으로 구하면 곱하기 전에 long으로 바꿔야 하며, 자연수 뒤집어 배열로 만들기는 long 값을 int 배열에 넣으면서 (int)를 빠뜨려 컴파일 에러가 났던 문제입니다.
TIP실습 코드 주석 중 사실만 살짝 다르게 적힌 곳이 있어 시험 전에 바로잡아 두면 좋습니다 — ① byte bb = (byte) i;의 주석은 "byte → int"로 되어 있지만 실제 방향은 int → byte입니다(큰 타입에서 작은 타입으로 가야 다운캐스팅이므로 값 손실도 이때 생깁니다). ② float f = 15.77f;의 주석 "8byte크기라 f 붙여줌"에서, float은 4byte이고 8byte는 double입니다 — f를 붙이는 이유는 "실수 리터럴의 기본 타입이 double(8byte)이라서 그대로 두면 float에 담을 수 없기 때문"입니다. 같은 줄의 "경고 띄움"도 정확히는 컴파일 에러입니다(f가 없으면 빨간 줄이 생기고 실행되지 않음). ③ main 옆 주석 "static으로 메모리에 이미 올라가 있음. 그래서 static이 없는 메서드를… 안됨"의 진짜 이유는 non-static 메서드는 호출 대상 객체(this)가 있어야 하는데 static 메서드에는 그 객체가 없기 때문입니다. ④ byte b3 = b1 + b2; 아래 주석 "연산을 하기전 b1,b2를 int 타입으로 변경해준 후 더해야한다"는 거꾸로입니다 — 더하는 순간 자바가 알아서 int로 승격하므로 미리 바꿀 필요가 없고, 문제는 그 int 결과를 byte 변수에 담는 대입에서 생깁니다. 그래서 해법은 더한 결과를 다시 (byte)로 캐스팅하는 것입니다(바로 다음 줄 코드가 정답). ⑤ byte b4 = 10 + 20;의 주석 "리터럴 정수는 기본 int 타입으로 인식하기 때문에 오류가 나지 않는다"도 이유가 틀립니다 — int라서라면 오히려 b1 + b2처럼 에러가 나야 합니다. 진짜 이유는 10 + 20이 컴파일 시점에 30으로 확정되는 상수식이고, 그 값이 byte 범위 안이라 컴파일러가 대입을 허용하기 때문입니다(byte b5 = 100 + 100;처럼 범위를 넘으면 에러). 코드 동작에는 영향이 없지만 객관식에서 정확히 갈리는 부분입니다.
한 줄 요약1일차의 "값을 담는 법"에서 "흐름을 제어하는 법"으로 넘어간 날 — 윤년 판별로 &&·|| 복합 조건을, 구구단·합계로 중첩 for문을, 주사위로 난수 두 가지(Math.random()·Random)와 while+break를, ATM 메뉴로 switch~case를 직접 만들어 봤고, 별 찍기 7종으로 중첩 for를 손에 붙였다.
쉽게 말하면1일차가 "상자에 값을 담는 법"이었다면 2일차는 "어느 길로 갈지 정하고(조건문), 몇 바퀴 돌지 정하는 법(반복문)"입니다. 갈림길이 두 개면 if~else, 갈림길이 여러 개면 switch, 같은 일을 반복하면 for, 언제 끝날지 모르면 while — 이 네 가지 조합이 오늘 만든 코드 전부의 뼈대예요.
① D1_isLeapYear — 복합 조건을 if 하나에 담기
윤년의 정의(4의 배수이면서 100의 배수가 아니거나, 400의 배수)를 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 한 줄로 옮겼습니다. 여기서 괄호가 핵심인데, &&가 ||보다 우선순위가 높아 사실 괄호가 없어도 같은 결과지만, 조건이 두 갈래라는 걸 눈으로 보이게 하려고 묶어 주는 게 관례입니다. 뒤이어 for문으로 2000~2030년의 윤년을 전부 뽑아 보며 조건문을 반복문 안에 넣는 첫 조합을 만들었습니다. → ☕ 08. 조건문: if · switch ~ case · ☕ 07. 연산자 총정리 · 🧪 실습과제 6번 윤년 구하기
② Scanner — nextInt()와 nextLine()+parseInt()
같은 "숫자 입력받기"를 두 파일에서 서로 다른 방식으로 썼다는 점이 오늘의 숨은 포인트입니다. D1에서는 sc.nextInt()를, D2에서는 Integer.parseInt(sc.nextLine())를 썼는데, 후자가 실무에서 더 안전합니다. nextInt()는 숫자만 가져가고 엔터(개행)를 버퍼에 남겨 뒤따르는 nextLine()이 그 개행을 읽고 빈 문자열을 반환하는 함정이 있기 때문입니다. 처음부터 한 줄을 통째로 읽어 숫자로 바꾸면 이 문제가 아예 생기지 않습니다. → ☕ 11. 키보드 입력 — Scanner와 InputStreamReader
③ D2_ControlEx — 중첩 for문의 규칙
구구단에서 바깥 for = 몇 단(행), 안쪽 for = 1~9(열)이라는 중첩 for의 기본형을 잡았습니다. 짝수단·홀수단은 증감식만 i += 2로 바꾸면 되고, 시작값을 2로 두면 짝수단, 3으로 두면 홀수단이 됩니다 — 조건문을 추가하지 않고 증감식으로 해결하는 게 더 간결합니다. 1~100 합은 sum += i, 4의 배수 합은 그 안에 if (i % 4 == 0)를 하나 넣어 확장했습니다. → ☕ 09. 반복문: for · while · do~while
④ 난수 두 가지와 while + break
주사위 두 개의 합이 5가 될 때까지 굴리는 코드를 같은 로직·다른 도구로 두 번 작성했습니다. (int)(Math.random() * 6) + 1은 0.0 이상 1.0 미만의 실수를 6배 한 뒤 정수로 잘라 0~5를 얻고 +1로 1~6을 만드는 방식이고, new Random().nextInt(6) + 1은 처음부터 0~5의 정수를 주므로 캐스팅이 필요 없어 더 직관적입니다. 반복 횟수를 모르므로 for가 아니라 while을 쓰고, 조건이 맞으면 break로 빠져나옵니다. → ☕ 09. 반복문
⑤ switch~case로 만든 ATM 메뉴
예금·출금·잔고·종료 네 갈래를 switch로 나누고, 전체를 while (start)로 감싸 종료를 고를 때까지 메뉴가 계속 뜨는 구조를 만들었습니다. 각 case 끝의 break를 빼면 아래 case로 흘러내려가고(fall through), 어디에도 해당하지 않는 입력은 default가 받습니다. 종료는 start = false로 반복 조건 자체를 끄는 방식 — 이 패턴이 콘솔 프로그램의 기본 뼈대입니다. 출금 시 if (outTemp > savemoney)로 잔고를 먼저 검사하는 것도 실전 감각이 들어간 부분입니다. → ☕ 08. 조건문: if · switch ~ case
⑥ prac01 — 별 찍기 7종으로 굳히기
수업 외에 스스로 연습한 파일입니다. 별 찍기의 공식은 하나뿐이에요 — 바깥 for는 "줄", 안쪽 for는 "그 줄에 찍을 개수". 나머지는 개수를 정하는 식만 달라집니다. 직각삼각형은 별 개수가 i, 역삼각형은 6 - i, 피라미드는 공백 line-1-i개 + 별 2i+1개로 좌우 대칭을 만듭니다. 다이아몬드는 정피라미드 뒤에 역피라미드를 붙이되 i = c - 2부터 시작해 가운데 줄이 두 번 찍히는 것을 방지했습니다(본인 주석에 정확히 기록돼 있음). → ☕ 09. 반복문
JAVAD1_isLeapYear.java (수업 실습 원본)
package hk.edu20260804.day02;
import java.util.Scanner;
//파일명이 클래스명과 동일해야 함
public class D1_isLeapYear {
// 윤년: 1년은 365일 --> 366일인 해, 2월달의 마지막날이 29일
// 윤년을 판단하는 조건을 확인
// -년도가 4의 배수이면서, 100으로 나누어떨어지지 않는 수
// -또는 400으로 나누어 떨어지는 수
// 2026년도가 윤년인지 아닌지 확인해서 출력해보기
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.print("년도를 입력해주세요 : ");
int year = sc.nextInt();
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {
System.out.println(year + "는 윤년입니다");
} else {
System.out.println(year + "는 평년입니다");
}
for (int i = 2000; i < 2031; i++) {
if ((i % 4 == 0 && i % 100 != 0) || i % 400 == 0) {
System.out.println(i + "는 윤년입니다");
}
}
}
public static boolean isLeapYear(int year) {
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) {
return true;
} else {
return false;
}
}
}
첫 줄이 년도를 입력해주세요 : 2026는 평년입니다처럼 붙어 보이는 건 print(줄바꿈 없음) 뒤에 입력이 오고 이어서 println이 찍혔기 때문입니다. 2026은 4의 배수가 아니라 평년이고, 아래 목록의 2000년은 100의 배수지만 400의 배수라서 윤년으로 잡힌 것 — 조건식의 || year % 400 == 0이 실제로 일한 자리입니다.
JAVAD2_ControlEx.java (수업 실습 원본 — 주석 처리된 블록은 앞서 차례로 실습한 내용)
package hk.edu20260804.day02;
import java.util.Random;
import java.util.Scanner;
public class D2_ControlEx {
public static void main(String[] args) {
// System.out.print("========2단==========\n");
// // 구구단
// // 2단
// for (int i = 1; i <= 9; i++) {
// System.out.printf("\"2X%d=%d\"", i, 2 * i);
// System.out.println();
// }
// System.out.print("=========2~9단==========\n");
// // 2~9단
// for (int i = 2; i <= 9; i++) {
// for (int j = 1; j <= 9; j++) {
// System.out.printf("%dx%d=%d", i, j, i * j);
// System.out.print("\t");
// }
// System.out.println();
// }
// System.out.print("=========짝수단만==========\n");
// // 2~9단 출력하는데 짝수단만 출력
// for (int i = 2; i <= 9; i += 2) {
// for (int j = 1; j <= 9; j++) {
// System.out.printf("%dx%d=%d", i, j, i * j);
// System.out.print("\t");
// }
// System.out.println();
// }
// System.out.print("=========홀수단만==========\n");
// // 2~9단 출력하는데 홀수단만 출력
// for (int i = 3; i <= 9; i += 2) {
// for (int j = 1; j <= 9; j++) {
// System.out.printf("%dx%d=%d", i, j, i * j);
// System.out.print("\t");
// }
// System.out.println();
// }
// System.out.print("=========100까지 합==========\n");
// // 1~100까지의 합 출력
// int sum = 0;
// for (int i = 1; i <= 100; i++) {
// sum += i;
// }
// System.out.println(sum);
// System.out.print("=========4의 배수의 총합==========\n");
// // 1~100까지의 수 중에 4의 배수의 총합 출력
// int sum2 = 0;
// for (int i = 1; i <= 100; i++) {
// if (i % 4 == 0) {
// sum2 += i;
// }
// }
// System.out.println(sum2);
// System.out.print("=========두 주사위==========\n");
// // 주사위 두개의 합이 5이면 실행을 멈추고
// // 5가 아니면 계속 실행되게 코드를 작성하자
// // 1~6까지의 숫자로 구성, 랜덤하게 숫자 생성하는 기능
// // Math객체사용
// boolean stop = true;
// while (stop) {
// int first = (int) (Math.random() * 6) + 1;
// int second = (int) (Math.random() * 6) + 1;
// System.out.printf("%d + %d = %d\n", first, second, first + second);
// if (first + second == 5) {
// stop = false;
// System.out.println("주사위의 합 5");
// break;
// }
// }
// Random random = new Random();
// boolean stop2 = true;
// while (stop2) {
// int first2 = random.nextInt(6) + 1;
// int second2 = random.nextInt(6) + 1;
// System.out.printf("%d + %d = %d\n", first2, second2, first2 + second2);
// if (first2 + second2 == 5) {
// stop2 = false;
// System.out.println("주사위의 합 5");
// break;
// }
// }
// // Scanner 클래스: 키보드로 입력받는 기능에 활용해 볼 수 있는 객체
// Scanner scan = new Scanner(System.in);
// int num = 0;
// System.out.print("숫자를 입력하세요 : ");
// num = Integer.parseInt(scan.nextLine());
// System.out.println("입력결과값:" + num);
// System.out.println("또입력받기:");
// int num2 = Integer.parseInt(scan.nextLine());
// System.out.println("입력결과값:" + num2);
// scan.close();
Scanner sc = new Scanner(System.in);
boolean start = true;
int savemoney = 0;
while (start) {
System.out.println("옵션을 선택하세요");
System.out.println("---------------------");
System.out.println("1.예금 2.출금 3.잔고 4.종료");
System.out.print("선택 >> ");
int select = Integer.parseInt(sc.nextLine());
switch (select) {
case 1:
System.out.println("예금액 : ");
int saveTemp = Integer.parseInt(sc.nextLine());
savemoney += saveTemp;
break;
case 2:
System.out.println("출금액 : ");
int outTemp = Integer.parseInt(sc.nextLine());
if (outTemp > savemoney) {
System.out.println("잔고가 부족합니다");
} else {
savemoney -= outTemp;
}
break;
case 3:
System.out.println("잔고 : " + savemoney);
break;
case 4:
System.out.println("종료합니다");
start = false;
break;
default:
System.out.println("잘못된 입력입니다");
break;
}
}
}
}
파일의 앞부분(구구단·합계·주사위)은 전부 주석 처리돼 있어 실제로 도는 것은 ATM 메뉴뿐입니다. 예금액 : 뒤가 비어 보이는 건 이 출력이 입력값을 자동으로 넣어 주며 실행한 결과라 입력 줄을 생략했기 때문이고(Eclipse 콘솔에서 직접 치면 입력한 값도 화면에 보입니다), 흐름은 예금 50000 → 잔고 50000 → 출금 20000 → 잔고 30000 → 종료입니다. 메뉴가 계속 다시 뜨는 것이 while (start)가 하는 일입니다.
JAVAprac01.java (스스로 연습 — 별 찍기 7종)
package hk.practice;
public class prac01 {
public static void main(String[] args) {
for (int i = 0; i < 6; i++) {
for (int j = 0; j < i; j++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
for (int i = 1; i < 6; i++) {
for (int j = 6; j > i; j--) {
System.out.print("*");
}
System.out.println();
}
int line = 5;
for (int i = 0; i < line; i++) {
for (int j = 0; j < line - 1 - i; j++) {
System.out.print(" ");
}
for (int k = 0; k < 2 * i + 1; k++) {
System.out.print("*");
}
System.out.println();
}
for (int i = 0; i < 6; i++) {
for (int j = 0; j < 6 - i; j++) {
System.out.print(" ");
}
for (int k = 0; k < i; k++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
for (int k = 0; k < 6; k++) {
for (int i = 0; i < k; i++) {
System.out.print(" ");
}
for (int i = 6; i > k; i--) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
int a = 5;
for (int i = a; i > 0; i--) {
for (int k = 0; k < a - i; k++) {
System.out.print(" ");
}
for (int j = 0; j < 2 * i - 1; j++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
int c = 5; // 위쪽 피라미드 높이
// 1. 위쪽 정피라미드 (i = 0 ~ 4)
for (int i = 0; i < c; i++) {
for (int k = 0; k < c - 1 - i; k++) {
System.out.print(" ");
}
for (int j = 0; j < 2 * i + 1; j++) {
System.out.print("*");
}
System.out.println();
}
// 2. 아래쪽 역피라미드 (i = 3 ~ 0) i = a - 2 부터 시작해서 가운데 줄 중복 방지!
for (int i = c - 2; i >= 0; i--) {
for (int k = 0; k < c - 1 - i; k++) {
System.out.print(" ");
}
for (int j = 0; j < 2 * i + 1; j++) {
System.out.print("*");
}
System.out.println();
}
}
}
첫 줄과 네 번째 그림의 첫 줄이 비어 보이는 이유는 i가 0일 때 안쪽 for가 한 번도 돌지 않아 별을 0개 찍기 때문입니다(공백만 찍힌 줄). i를 1부터 시작하거나 개수 식을 i+1로 바꾸면 사라집니다. 마지막 다이아몬드는 아래쪽을 i = c-2부터 시작해 가운데 줄이 두 번 찍히지 않는다는 걸 출력에서 확인할 수 있습니다.
2000이 빠집니다. 2000은 4의 배수지만 100의 배수이기도 해서 앞쪽 괄호 (i % 4 == 0 && i % 100 != 0)가 false이고, 윤년으로 잡힌 이유는 오직 400의 배수 조건 덕분이었습니다. 2004~2028은 100의 배수가 아니라서 그대로 남습니다.
D1처럼 sc.nextInt()로 연도를 받은 직후 sc.nextLine()으로 이름을 받으면 어떻게 될까?
이름을 칠 기회도 없이 빈 문자열 ""이 바로 들어옵니다. nextInt()는 숫자만 가져가고 엔터(개행)를 버퍼에 남기는데, 뒤의 nextLine()이 그 개행을 "한 줄"로 읽어 버리기 때문입니다. 처음부터 Integer.parseInt(sc.nextLine())으로 받으면(D2 방식) 이 문제가 생기지 않습니다.
ATM 메뉴에서 case 1 끝의 break; 를 지우고 1을 고르면 어떻게 될까?
예금을 처리한 뒤 멈추지 않고 아래 case 2로 흘러내려가(fall through) "출금액 :"까지 출력하고 출금액을 또 입력받습니다. case 2 끝의 break를 만나서야 switch를 빠져나옵니다. case는 "여기서부터 실행" 표시일 뿐이라, 끝을 break로 막아야 그 갈래만 실행됩니다.
💼 실무·코딩테스트에서는오늘의 while + switch 메뉴 구조는 웹으로 가면 요청 주소나 command 값에 따라 처리를 나누는 컨트롤러가 됩니다. 입력을 문자열로 받아 parseInt로 바꾸는 습관도 그대로 이어지는데, 웹의 요청 값은 전부 문자열로 들어오기 때문입니다. Java 14부터 쓰는 화살표 switch(case 1 -> ...)는 fall through가 없어 break 실수를 원천 차단하므로 요즘 코드에서 자주 보입니다. 코딩테스트에서는 조건 + 반복 + % 조합이 가장 먼저 나옵니다 — 저주의 숫자3(걸리는 수를 while로 건너뛰기), 나머지가 1이 되는 수 찾기(작은 수부터 나눠 보다 break)가 그 예입니다.
TIPD1의 isLeapYear(int year) 메서드는 만들어만 두고 main에서 쓰지 않았습니다. main의 if를 if (isLeapYear(year))로, for문 안의 조건도 if (isLeapYear(i))로 바꾸면 같은 조건식을 세 번 쓰지 않아도 되고, 이게 바로 메서드로 분리하는 이유입니다. 덧붙여 if (조건) return true; else return false;는 그냥 return 조건; 한 줄로 줄일 수 있습니다 — 조건식 자체가 이미 true/false이기 때문입니다. 시험에서도 자주 나오는 축약이니 눈에 익혀 두세요.
한 줄 요약2일차 끝에 손대기 시작한 별 찍기를 8종으로 늘려 "줄마다 찍을 공백·별 개수를 i에 대한 식으로 세우는" 감각을 굳혔고, 이어서 D2_MethodTest로 메서드를 static / non-static, 매개변수 유무, 반환값 유무로 나눠 보며 객체지향의 입구에 발을 들였다.
쉽게 말하면별 찍기는 사실 도형 그리기가 아니라 "n번째 줄에 몇 개를 찍을지"를 계산하는 식 세우기 연습이에요. 그림이 달라 보여도 바깥 for(줄)와 안쪽 for(개수)는 그대로고, 개수를 정하는 식만 바뀝니다. 뒤이어 배운 메서드는 자주 쓰는 코드에 이름을 붙여 둔 것인데, 오늘은 그 이름표를 붙이는 방식 — 객체 없이 바로 쓸지(static), 값을 받을지(매개변수), 값을 돌려줄지(반환) — 를 나눠 봤습니다.
① D1_StarView — 별 찍기 8종, 공식은 하나뿐
패턴이 여덟 개지만 뼈대는 전부 같습니다. 바깥 for = 줄 번호 i, 안쪽 for = 그 줄에 찍을 개수. 좌측 정렬 직각삼각형은 별이 i개, 뒤집으면 6 - i개. 오른쪽 정렬로 밀고 싶으면 별을 찍기 전에 공백을 먼저 찍는 for문을 하나 더 붙이면 됩니다. 피라미드는 여기서 한 걸음 더 나아가 공백 line-1-i개 + 별 2i+1개 — 별 개수를 홀수(1, 3, 5, 7…)로 늘려야 좌우 대칭이 맞기 때문입니다. → ☕ 09. 반복문: for · while · do~while
② 등차수열로 보면 i--도 전부 i++로 바뀐다
코드 안에 직접 남긴 주석 두 줄 — // 10 8 6 4 2 -> 10 + (n-1)* -2, // 등차수열공식으로 하면 ++로 다 할 수 있음 — 이 오늘의 핵심입니다. 줄마다 개수가 일정하게 늘거나 줄면 그건 등차수열 a + (n-1)d이고, 첫항 a와 공차 d만 정하면 됩니다. 단 주석의 "10 8 6 4 2"는 예시일 뿐이고, 바로 아래 for (int i = a; i > 0; i--) 역피라미드가 실제로 찍는 별 개수는 9 7 5 3 1(첫항 9, 공차 -2 → 9 + (n-1)*-2)입니다. 이 역피라미드도 for (int i = 0; i < 5; i++)로 두고 별 개수 식만 9 - 2*i(= (5-i)*2-1)로 바꾸면 i++만으로 똑같이 나옵니다. 코드 마지막 블록(// 등차수열공식으로 하면 ++로 다 할 수 있음)은 역피라미드가 아니라 세 번째 그림인 정피라미드를 (i+1)*2-1 식으로 다시 세운 것이고, 출력에서 세 번째 그림과 똑같이 나오는 것으로 확인됩니다. 반복 방향이 아니라 식이 본질이라는 감각을 잡은 셈입니다.
③ D2_MethodTest — main은 진입점, 그리고 static의 벽
main은 프로그램이 가장 먼저 들어오는 문(진입점)이고, 다른 메서드는 여기서 불러 줘야 실행됩니다. 그런데 main은 static이라 특정 객체에 속하지 않습니다. non-static 메서드는 "어느 객체의 메서드인지(this)"가 있어야 호출되는데 static 메서드 안에는 그 객체가 없으니 직접 부를 수 없습니다(코드 주석의 "메모리에 올라가 있지 않음"은 정확한 이유가 아니니 이렇게 고쳐 이해하세요 — 메서드 코드 자체는 클래스가 로딩될 때 이미 Method Area에 있습니다). 그래서 test02()를 쓰려면 new D2_MethodTest()로 객체를 만들어 methodTest.test02()로 호출해야 합니다. 반대로 test01()은 static이므로 클래스명.메서드()로 객체 없이 바로 부를 수 있죠. 1일차에 testMethod()에 static을 붙여 해결했던 그 문제를, 이번엔 객체를 만드는 쪽으로 풀어 본 것입니다. → 🧱 05. static vs non-static과 메모리 3영역
④ 메서드 유형 — (매개변수 O/X) × (반환값 O/X)
메서드는 매개변수가 있나/없나 × 반환값이 있나/없나로 네 칸이 나옵니다. 수업 코드에는 세 칸만 있고, "받기만 하고 돌려주지 않는" 칸은 예시가 없어 아래 표에 직접 만든 예(printMax)를 넣었습니다.
반환값 없음(void)
반환값 있음
매개변수 없음
void test04() — 실행만 하고 끝
int test03() — 받지 않고 돌려주기만
매개변수 있음
void printMax(int a, int b) — 받아서 출력만(수업 코드에는 없는 예)
int test05(int a, int b) — 받아서 계산해 돌려줌
반환타입을 void가 아닌 것으로 선언했다면 모든 경로에서 반드시 그 타입을 return 해야 하고, 그러지 않으면 컴파일 에러가 납니다. test05는 두 수 중 큰 값을 돌려주는데, 여기서 res를 0으로 초기화한 건 사실 필수가 아닙니다 — if와 else 양쪽에서 모두 값을 대입하므로 int res;로만 선언해도 컴파일러가 "return 전에 반드시 값이 들어간다"는 걸 알아서 통과시킵니다. 초기화가 꼭 필요한 건 else가 없어 값이 안 들어가는 경로가 생길 때입니다("지역변수는 값을 넣기 전에 읽을 수 없다"). → 🧱 03. 메서드의 구조 — 리턴타입 · parameter · argument
JAVAD1_StarView.java (수업 실습 원본)
package hk.edu20260805.day03;
public class D1_StarView {
public static void main(String[] args) {
for (int i = 0; i < 6; i++) {
for (int j = 0; j < i; j++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
for (int i = 1; i <= 6; i++) {
for (int j = 6; j > i; j--) {
System.out.print("*");
}
System.out.println(" ");
}
System.out.println();
int line = 5;
for (int i = 0; i < line; i++) {
for (int j = 0; j < line - 1 - i; j++) {
System.out.print(" ");
}
for (int k = 0; k < i * 2 + 1; k++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
for (int i = 0; i < 6; i++) {
for (int j = 0; j < 6 - i - 1; j++) {
System.out.print(" ");
}
for (int k = 0; k < i; k++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
for (int i = 0; i < 6; i++) {
for (int k = 0; k < i; k++) {
System.out.print(" ");
}
for (int j = 0; j < 6 - i - 1; j++) {
System.out.print("*");
}
System.out.println();
}
// 10 8 6 4 2 -> 10 + (n-1)* -2
int a = 5;
for (int i = a; i > 0; i--) {
for (int k = 0; k < 5 - i; k++) {
System.out.print(" ");
}
for (int j = 0; j < i * 2 - 1; j++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
int c = 5;
for (int i = 0; i < c; i++) {
for (int j = c; j > i + 1; j--) {
System.out.print(" ");
}
for (int k = 0; k < 2 * i + 1; k++) {
System.out.print("*");
}
System.out.println();
}
for (int i = 4; i > 0; i--) {
for (int j = 0; j < 5 - i; j++) {
System.out.print(" ");
}
for (int k = 0; k < i * 2 - 1; k++) {
System.out.print("*");
}
System.out.println();
}
System.out.println();
// 등차수열공식으로 하면 ++로 다 할 수 있음
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 5 - (i + 1); j++) {
System.out.print(" ");
}
for (int k = 0; k < (i + 1) * 2 - 1; k++) {
System.out.print("*");
}
System.out.println();
}
}
}
두 번째 그림에서 별 뒤에 공백이 한 칸씩 붙는 건 System.out.println(" ")로 빈 문자열이 아니라 공백을 출력했기 때문입니다(println()으로 바꾸면 깔끔해집니다). 그리고 세 번째 그림과 마지막 그림이 완전히 같습니다 — 하나는 line-1-i·2i+1로, 다른 하나는 등차수열 식 5-(i+1)·(i+1)*2-1로 세운 것이라 식만 달라도 결과가 같다는 걸 출력이 증명합니다.
JAVAD2_MethodTest.java (수업 실습 원본)
package hk.edu20260805.day03;
public class D2_MethodTest {
// main 메서드는 프로그램을 실행시키는 진입점(시작점)
// 구현된 메서드를 실행시켜주는 메서드
public static void main(String[] args) {
// static 메서드 사용: 클래스명.메서드() 호출해서 사용
D2_MethodTest.test01();
// non-static 메서드 사용: 객체생성후 메서드 호출
D2_MethodTest methodTest = new D2_MethodTest();
methodTest.test02();
}
// 메서드의 유형
// 1.static과 non-static 유형
public static void test01() {
System.out.println("static 메서드");
// test01() 은 이미 메모리에 올라가 있음
// test02() 는 메모리에 올라가 있지 않음
// non-static을 사용못함->객체생성이나, static으로 만들면 사용가능
D2_MethodTest methodTest = new D2_MethodTest();
methodTest.test02();
}
// non-static 메서드 -> 객체생성해야지만 사용 가능
public void test02() {
System.out.println("non-static 메서드");
}
// 2. 매개변수와 반환유무에 따른 유형
// 매개변수 없음, 반환값 없음
public int test03() {
return 0;// 반환타입을 설정했다면 반드시 해당 타입을 반환
}
// 반환타입X는 코드만 실행하고 끝내는 경우
public void test04() {
}
// 3. 파라미터 O/X : 외부로부터 값을 받아서 뭔가 실행하려고
public int test05(int a, int b) {
int res = 0;
if (a > b) {
res = a;
} else {
res = b;
}
return res;
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
static 메서드
non-static 메서드
non-static 메서드
non-static 메서드가 두 번 찍힙니다 — main에서 객체를 만들어 한 번, static인 test01() 안에서 다시 객체를 만들어 또 한 번 불렀기 때문입니다. "static은 non-static을 직접 못 쓰고 객체가 있어야 한다"는 규칙이 출력 횟수로 드러난 자리입니다. test03·test04·test05는 정의만 하고 호출하지 않아 아무것도 찍히지 않습니다.
3일차에 배운 것 — 핵심 정리
별 찍기의 기본형: 바깥 for = 줄, 안쪽 for = 그 줄에 찍을 개수. 도형이 달라도 개수 식만 바뀐다.
오른쪽 정렬·피라미드는 공백 for문을 별 for문 앞에 둔다. 피라미드 = 공백 n-1-i개 + 별 2i+1개.
줄마다 개수가 일정하게 변하면 등차수열 a + (n-1)d — i--로 거꾸로 도는 대신 식을 바꿔 i++로 통일할 수 있다.
다이아몬드 = 정피라미드 + 역피라미드. 이어 붙일 때 가운데 줄이 두 번 찍히지 않게 시작값을 한 칸 당긴다.
main은 프로그램의 진입점이며 static이다.
static 메서드는 클래스명.메서드()로, non-static 메서드는 객체 생성 후 참조변수.메서드()로 호출한다.
static 메서드 안에서 non-static 메서드를 쓰려면 객체를 만들어야 한다(또는 그 메서드도 static으로).
반환타입을 선언했으면 반드시 그 타입을 return해야 하고, 실행만 하고 끝낼 때는 void를 쓴다.
메서드 유형 = (매개변수 있음/없음) × (반환값 있음/없음) 네 가지 조합.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D2_MethodTest를 실행하면 "non-static 메서드"가 왜 두 번 찍힐까?
main에서 객체를 만들어 methodTest.test02()로 한 번, static인 test01() 안에서도 객체를 새로 만들어test02()를 한 번 더 부르기 때문입니다. static 메서드 안에는 this가 없어 test02()를 그냥 부를 수 없으니, 두 곳 모두 객체를 거쳐야 했던 것입니다. test03~test05는 호출하지 않아 아무것도 찍지 않습니다.
test03() 위 주석은 "매개변수 없음, 반환값 없음"이다. 맞을까? return 0; 을 지우면?
주석이 틀렸습니다 — public int test03()은 매개변수는 없지만 int를 반환하는 형입니다. 반환타입을 int로 선언했으니 return 0;을 지우면 컴파일 에러(missing return statement)가 납니다. 정말 반환값이 없게 하려면 반환타입을 void로 바꿔야 합니다.
역피라미드 for (int i = a; i > 0; i--) (별 9·7·5·3·1개)를 i++로 도는 for문으로 바꾸려면 공백·별 개수를 어떻게 정할까?
for (int i = 0; i < 5; i++)로 두고 공백 i개, 별 9 - 2*i개로 하면 됩니다. 별은 첫항 9, 공차 -2인 등차수열 9 + (n-1)*(-2)이고 i가 0부터 시작하니 n-1 자리에 i가 들어갑니다. 공백은 원래 코드의 5 - i(i = 5→1)가 0·1·2·3·4였으니 그냥 i입니다 — 모양을 정하는 건 반복 방향이 아니라 식입니다.
💼 실무·코딩테스트에서는실무에서는 메서드 하나가 한 가지 일만 하고, 이름만 보고도 무엇을 하는지 알 수 있게 만듭니다. 결과를 화면에 찍기만 하는 void 메서드보다 값을 반환하는 메서드가 다른 곳에서 재사용하기 쉽고 테스트 코드로 검증하기도 쉽습니다. 필드(상태)가 필요 없는 계산은 Math.max()·Integer.parseInt()처럼 static 유틸리티 메서드로 둡니다. 코딩테스트(프로그래머스)는 solution 메서드가 답을 return하는 형식이라 "반환타입을 선언했으면 반드시 그 타입을 return"이 매 문제의 뼈대이고, 면접에서 "static 메서드에서 인스턴스 메서드를 바로 못 부르는 이유"를 물으면 "호출할 객체(this)가 없어서"라고 답하면 됩니다.
TIPtest03() 위에 달린 주석 // 매개변수 없음, 반환값 없음은 실제 코드(public int test03())와 어긋납니다 — 매개변수는 없지만 반환값은 있는 형이죠. 주석을 코드와 함께 고치는 습관이 시험에서도, 나중 협업에서도 그대로 점수가 됩니다. 그리고 test05의 if~else는 삼항연산자로 return a > b ? a : b; 한 줄로 줄일 수 있습니다. 다음 날 4일차 D1_divisor(약수 출력)에서 (i == a) ? i : i + ","처럼 출력할 값을 고르는 데 쓰게 될 그 도구를 반환에도 쓸 수 있다는 걸 기억해 두세요. → 📅 4일차 수업 진도
한 줄 요약문법에서 객체지향으로 넘어간 날 — 멤버필드가 객체마다 따로인지(인스턴스) 클래스가 공유하는지(static)를 눈으로 확인하고, 생성자 오버로딩과 this()·super(), private + getter/setter로 감싸는 캡슐화, 모든 클래스의 부모인 Object의 getClass · toString · hashCode · equals까지 훑었고, 실습과제의 약수 · 최대공약수 · 최소공배수 · 친화수 · 완전수를 메서드로 분리해 한 파일에 구현했다.
쉽게 말하면클래스는 붕어빵 틀, 객체는 그 틀로 구워 나온 붕어빵 하나하나입니다. 생성자는 굽는 순간 "팥이요, 슈크림이요"를 정하는 주문서고, 생성자 오버로딩은 주문서 양식을 여러 개 만들어 두는 것(아무 말 없으면 기본 맛, 크기만 말하면 크기만 바꿔 굽기). private은 "속 재료는 손대지 마세요"라고 포장해 두는 것이고, getter/setter는 그 포장을 여는 정해진 창구입니다. 창구를 거치게 만들면 "비밀번호를 아는 사람에게만 알려준다" 같은 조건을 끼워 넣을 수 있어요.
① D2_ClassTest — 인스턴스 변수 vs static 변수
파일 이름의 번호(D1~D4)와 아래 설명 순서는 다릅니다. 설명 순서는 클래스와 객체 → 생성자 → 캡슐화 → Object → 실습과제이고, 각 설명 첫머리의 [코드: …]가 아래 어느 코드 블록을 보면 되는지 알려 줍니다.
[코드: 첫 두 블록 D2_ClassTest · D2_ClassTestMain] 같은 클래스에 public int number;(인스턴스 변수)와 public static int staticNumber;(클래스 변수)를 나란히 두고, 객체를 두 개 만들어 값을 다르게 넣어 봤습니다. 결과는 classTest.number는 20, classTest2.number는 40으로 따로 관리되지만, D2_ClassTest.staticNumber = 50; 한 번에 두 객체가 모두 50을 봅니다 — static은 객체가 아니라 클래스에 하나만 존재하기 때문입니다. 메서드도 마찬가지여서 methodTest()는 객체명.메서드()로, stmethodTest()는 클래스명.메서드()로 호출했습니다. → 🧱 05. static vs non-static과 메모리 3영역 · 🧱 01. 클래스 · 객체 · 인스턴스
② 생성자 오버로딩과 this(...) — 초기화를 한 곳으로 모으기
[코드: D2_ClassTest의 this(10), 세 번째 블록 D4_Constructor의 this(24, "검정색")] 생성자는 객체가 만들어질 때 그 객체에 대해 한 번 실행되는 초기화 코드입니다(단 this(10)처럼 다른 생성자를 부르면 생성자 두 개가 이어서 실행되고, 그래도 객체는 하나만 만들어집니다). 아무것도 안 쓰면 컴파일러가 기본 생성자를 만들어 주지만, 파라미터가 있는 생성자를 하나라도 직접 만들면 기본 생성자는 더 이상 자동으로 생기지 않아 직접 선언해야 합니다(D2_ClassTest·D4_Constructor 둘 다 이 이유로 기본 생성자를 명시했습니다). 그리고 기본 생성자 안에서 this(10), this(24, "검정색")처럼 자기 자신의 다른 생성자를 호출해 초기화 로직을 한 곳에 모았습니다 — 이때 this(...)는 반드시 생성자의 첫 줄이어야 합니다. 파라미터 이름과 필드 이름이 같을 때 this.size = size;로 구분하는 것도 같은 this지만 "이 객체 자신의 필드"라는 다른 쓰임입니다. → 🧱 07. 생성자와 생성자 오버로딩 · 🧱 06. 오버로딩
③ super()와 this()는 함께 쓸 수 없다
[코드: 맨 마지막 블록 D1_divisor의 생성자 부분]D1_divisor의 생성자에 super();를 직접 써 보고, 그 아래 // this(); // 같이 작성할 수 없다를 주석으로 남긴 부분이 오늘의 중요한 한 줄입니다. 생성자의 첫 줄에는 super(...)나 this(...) 둘 중 하나만 올 수 있습니다(둘 다 "첫 줄" 자리를 요구하니까요). 아무것도 쓰지 않으면 컴파일러가 super()를 몰래 넣어 줍니다. 그런데 부모를 선언한 적이 없는데 무슨 부모냐 하면 — 자바의 모든 클래스는 자동으로 Object를 상속하기 때문입니다. → 🧱 20. this와 super, 참조타입 형 변환
④ D4_Constructor — private과 getter/setter (캡슐화)
[코드: D4_Constructor + 실행 쪽 D1_ConstructioMain] TV를 클래스로 만들면서 size는 private, color는 public으로 두고 차이를 확인했습니다. main에서 tv4.color = "파란색";은 바로 되지만 tv4.size = 24;는 컴파일 자체가 안 되고tv4.setSize(24);를 거쳐야 합니다. 그럼 왜 굳이 막느냐 — getSize(int pw)가 그 답을 보여줍니다. 비밀번호가 1234일 때만 값을 돌려주고 아니면 -1을 반환하죠. 필드를 직접 열어 두면 아무 값이나 들어오고 아무나 읽지만, 메서드로 감싸면 조건을 끼워 넣을 수 있다 — 이게 캡슐화를 하는 이유 그 자체입니다. → 🧱 04. 접근 제한자 4가지
⑤ D3_ObjectTest — Object의 4대 메서드와 String pool
[코드: D3_ObjectTest]getClass()는 패키지.클래스명을, toString()은 재정의하지 않았을 때 패키지.클래스명@해시코드(16진수)를, hashCode()는 같은 값을 10진수로 돌려줍니다.
Object의 기본 equals()는 주소 비교입니다 — ==와 똑같이 "같은 객체인가"만 봅니다. D3_ObjectTest는 equals를 재정의하지 않았으니 ot.equals(str2)는 이 기본 equals가 돌고, ot와 str2는 서로 다른 객체라 false입니다.
반면 String은 이 기본 equals를 "글자 내용이 같은가"로 재정의해 두었습니다. 그래서 String끼리는 객체가 달라도 내용이 같으면 equals가 true가 됩니다. 이어서 String s = "a"; String s2 = "a";는 s == s2가 true — 리터럴은 String pool에 하나만 만들어 두고 공유하니까요. 반면 new String("a")는 힙에 새 객체를 만들어 s == s3는 false, s.equals(s3)는 true가 됩니다. == 는 주소, equals는 내용 — 시험 단골입니다. → 🧱 02. Object 클래스와 4대 메서드 · 🧱 12. String pool 메모리 구조 · 🧱 14. == 와 equals의 차이
⑥ D1_divisor — 실습과제를 "메서드로 쪼개서" 풀기
[코드: 맨 마지막 블록 D1_divisor] 3일차에 배운 메서드를 바로 써먹은 파일입니다. 약수(divisor) → 최대공약수(getGcd·getGcd1·getGcd2) → 최소공배수(lowestMultiple) → 진약수 합(sumDivisor) → 친화수(amicable) · 완전수(perfectnum)로, 앞에서 만든 메서드를 뒤에서 재사용하는 구조가 깔끔하게 잡혔습니다. 최대공약수는 유클리드 호제법 세 가지 버전으로 만들었는데, ⓐ 나눗셈 반복(a % b를 b가 0이 될 때까지), ⓑ 큰 수에서 작은 수를 계속 빼기, ⓒ 같은 식을 재귀 호출(getGcd2(b, a % b))로 옮긴 것입니다 — 셋 다 결과는 같고, 재귀 버전이 가장 짧습니다. 최소공배수는 (a * b) / gcd 공식을 그대로 옮겼고, 친화수는 i != sumDivisor(i) && i == sumDivisor(sumDivisor(i))로 "자기 자신(=완전수)은 제외"하는 조건까지 정확히 넣었습니다. static 메서드는 바로 호출하고 non-static인 amicable·perfectnum은 객체 d를 만들어 호출한 것도 3일차 내용의 복습입니다. → 🧪 1번 약수 · 🧪 2번 최대공약수 · 🧪 3번 최소공배수 · 🧪 4번 친화수 · 🧪 5번 완전수
JAVAD2_ClassTest.java (수업 실습 원본 — 클래스 쪽)
package hk.edu20260806.day04;
public class D2_ClassTest {
// 멤버필드: 클래스에서 데이터를 저장해서 사용하는 저장공간 개념
// -> 객체가 사라지지 않는 한 항상 유지 됨
public int number; // 인스턴스 멤버필드 (변수)
public static int staticNumber; // 클래스 변수
// 기본 생성자(deafult생성자) 파라미터 없음, 생략 가능,
// //멤버필드 초기화나 초기 실행할 작업
// 아래와 같이 파라미터가 있는 생성자를 추가하면 default생성자 생략 못함
public D2_ClassTest() {
// 자기 자신의 생성자 호출
// 객체 생성할때 default 생성자를 호출하면 10으로 초기화함
// this.number10;
this(10);
}
// 생성자 오버로딩: 파라미터의 개수와 타입을 다르게 해서 생성자나 메서드 이름을 같게 사용
public D2_ClassTest(int number) {
// super:부모, this:자기자신 클래스
this.number = number; // this->이 객체 자신을 의미. 멤버필드 number를 지칭
}
// 메서드: 인스턴스 메서드
public void methodTest() {
System.out.println("인스턴스에 관련된 기능을 정의한다");
}
// 메서드: 클래스 메서드
public static void stmethodTest() {
System.out.println("메서드영역 메모리에 생성되어 공통기능을 정의한다.");
}
}
JAVAD2_ClassTestMain.java (수업 실습 원본 — 실행 쪽)
package hk.edu20260806.day04;
public class D2_ClassTestMain {
public static void main(String[] args) {
// 참조타입 객체명 생성자
D2_ClassTest classTest = new D2_ClassTest(); // Heap 메모리에 생성
classTest.methodTest(); // 객체명.메서드로 호출 -> 인스턴스 메서드
classTest.number = 20; // 객체명.멤버필드로 호출 -> 인스턴스 변수
D2_ClassTest.stmethodTest();// 클래스명.메서드로 호출 -> 정적 메서드
// 객체 생성을 또 할 수 있다.
D2_ClassTest classTest2 = new D2_ClassTest(30);
classTest2.number = 40;
// 인스턴스변수는 각각의 해당 객체에서 관리됨
System.out.println("classTest.number:" + classTest.number);
System.out.println("classTest2.number:" + classTest2.number);
// static 변수는 클래스 전체에서 공유됨
D2_ClassTest.staticNumber = 50;
System.out.println("classTest.staticNumber:" + classTest.staticNumber);
System.out.println("classTest2.staticNumber:" + classTest2.staticNumber);
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
인스턴스에 관련된 기능을 정의한다
메서드영역 메모리에 생성되어 공통기능을 정의한다.
classTest.number:20
classTest2.number:40
classTest.staticNumber:50
classTest2.staticNumber:50
number는 20과 40으로 서로 다르고, staticNumber는 D2_ClassTest.staticNumber = 50 한 줄에 둘 다 50이 됩니다 — 인스턴스 변수는 객체마다, static 변수는 클래스에 하나뿐이라는 차이가 그대로 보입니다. 참고로 classTest는 기본 생성자의 this(10)으로 10이 들어갔다가 classTest.number = 20에 덮어써진 상태입니다.
package hk.edu20260806.day04;
public class D4_Constructor {
// 티비객체
// private은 클래스 내부에서만 접근 가능
private int size = 0; // 중요한 데이터 -->private 선언
public String color = "검정석"; // 색상
// default 생성자: 단독으로 사용한다면 생략 가능 -> 오버로딩을 하면 생략 못함
public D4_Constructor() {
// this.size = 24;
// this.color = "검정색";
// System.out.println(); //생성자는 가장 처음에 실행되어야 하므로 첫줄에 작성
this(24, "검정색"); // 생성자 호출은 반드시 첫줄에 작성
}
// 생성자 오버로딩
public D4_Constructor(int size) {
// 멤버필드size = 파라미터 size
this.size = size;
}
public D4_Constructor(int size, String color) {
// 멤버필드size = 파라미터 size
this.size = size;
this.color = color;
}
// private으로 선언한 맴버필드는 어떻게 접근할까?->getter, setter메서드를 사용한다
public int getSize(int pw) {
// 조건에 따라 값을 반환한다. ex: 비밀번호를 알고 있다면 반환
if (pw == 1234) {
return size;
} else {
System.out.println("잘못된 비밀번호");
return -1;
}
}
public void setSize(int size) {
this.size = size;
}
}
package hk.edu20260806.day04;
public class D1_ConstructioMain {
public static void main(String[] args) {
D4_Constructor tv = new D4_Constructor();
D4_Constructor tv2 = new D4_Constructor(70);
D4_Constructor tv3 = new D4_Constructor(60, "노랑색");
// 생성자 이용 안하고, 직접 멤버필드에 접근해서 초기화 할경우 불편하다
D4_Constructor tv4 = new D4_Constructor();
tv4.color = "파란색";
tv4.setSize(24); // private이라 메서드 통해 값 추가
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
(출력 없음 — 이 프로그램은 화면에 아무것도 찍지 않습니다)
이 프로그램은 객체 네 개를 만들기만 하고 아무것도 출력하지 않습니다 — 에러가 난 게 아니라 println이 한 줄도 없어서 그렇습니다. 생성자가 어떤 값으로 돌았는지 보려면 생성자 안에 System.out.println(size + " / " + color);를 넣어 보세요. 그러면 tv2 = new D4_Constructor(70)이 size만 받는 생성자라 color가 필드 초기값 그대로 남는 것도 눈으로 확인됩니다.
JAVAD3_ObjectTest.java (수업 실습 원본)
package hk.edu20260806.day04;
public class D3_ObjectTest {
public static void main(String[] args) {
// Object 클래스: 최상위 객체
// getClass(): 클래스의 위치를 반환한다. -> 패키지.클래스명
String str = new String("Object");
String str2 = "ObjectLit"; // 주로 사용되는 방식
System.out.println(str.getClass());
System.out.println(str2.getClass());
D3_ObjectTest ot = new D3_ObjectTest();
System.out.println(ot.getClass());
// toString(): 문자열로 반환한다
// target 객체에 "위치@hashcode(16진수)" 반환
System.out.println(ot.toString());
System.out.println(str.toString());
// hashcode(): 객체의 hashcode를 반환한다. -> 10진수로 표현
System.out.println(ot.hashCode());
// 객체를 비교할떄 hashcode로 비교한다. --> 일반적인 객체 비교할때는 의미가 없음
// --> equals()가 hashcode()를 이용해서 객체를 비교한다.
System.out.println(ot.equals(str2));
// 리터럴 방식 선언(String)
String s = "a";
String s2 = "a";
System.out.println(s == s2); // 비교연산자: 객체에 주소로 비교
System.out.println(s.equals(s2)); // hashcode로 비교
// 객체 생성으로 선언(new String())
String s3 = new String("a");
System.out.println(s == s3); // false //메모리 주소가 달라서
System.out.println(s.equals(s3)); // true --> 내용물이 같은지 봐서
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
class java.lang.String
class java.lang.String
class hk.edu20260806.day04.D3_ObjectTest
hk.edu20260806.day04.D3_ObjectTest@2ff4acd0
Object
804564176
false
true
true
false
true
@2ff4acd0과 804564176은 같은 값의 16진수·10진수 표기이고, 실행할 때마다 달라집니다 — 시험에서는 값이 아니라 패키지.클래스명@해시코드라는 형식만 기억하면 됩니다. 마지막 다섯 줄 false / true / true / false / true가 핵심입니다: 다른 객체와의 equals는 false, 리터럴 s == s2는 true(String pool 공유), new String("a")과는 ==가 false지만 equals는 true.
package hk.edu20260806.day04;
public class D1_divisor {
public D1_divisor() {
// 기본적으로 생성자 호출은 맨 윗줄에 작성
super(); // 부모생성자를 호출 ->Object 클래스가 부모임. 매개변수가 없으면 기본적으로 Object()호출
// this(); // 같이 작성할 수 없다
}
public static void main(String[] args) {
D1_divisor d = new D1_divisor();
d.divisor(12);
System.out.println("최대공약수1:" + getGcd1(12, 18));
System.out.println("최대공약수2:" + getGcd2(12, 18));
lowestMultiple(12, 18);
// amicable은 non-static메서드이기떄문에 객체생성해서 객체명.메서드로 호출한다.
d.amicable(1, 1000);
d.perfectnum(1, 1000);
}
// 약수를 구하는 메서드
public void divisor(int a) {
for (int i = 1; i < a + 1; i++) {
if (a % i == 0) {
System.out.print((i == a) ? i : i + ",");
}
}
System.out.println();
}
// 최대공약수 구하는 메서드 유클리드 호제법
public static int getGcd(int a, int b) {
while (b != 0) {
int r = a % b;
a = b;
b = r;
}
return a;
}
// 뺄셈 방식 유클리드 호제법
public static int getGcd1(int a, int b) {
while (a != b) {
if (a > b) {
a = a - b;
} else {
b = b - a;
}
}
return a;
}
// 유클리드 호제법 재귀호출
public static int getGcd2(int a, int b) {
if (b == 0) {
return a;
}
return getGcd2(b, a % b);
}
// 최소공배수
public static void lowestMultiple(int a, int b) {
int gcd = getGcd1(a, b);
int lowestMultiple = (a * b) / gcd;
System.out.println("최소공배수:" + lowestMultiple);
}
// 진약수 합
public static int sumDivisor(int a) {
int sum = 0;
for (int i = 1; i < a; i++) {
if (a % i == 0) {
sum += i;
}
}
return sum;
}
// 친화수
public void amicable(int s, int e) {
for (int i = s; i <= e; i++) {
if (i != sumDivisor(i) && i == sumDivisor(sumDivisor(i))) {
System.out.printf("%d와 %d는 친화수 관계입니다.\n", i, sumDivisor(i));
}
}
}
// 완전수
public void perfectnum(int s, int e) {
for (int i = s; i <= e; i++) {
if (i == sumDivisor(i)) {
System.out.println(i + "는 완전수입니다.");
}
}
}
}
최대공약수1(뺄셈 방식)과 최대공약수2(재귀)가 모두 6으로, 방법이 달라도 답이 같다는 걸 확인할 수 있습니다(getGcd는 정의만 하고 호출하지 않았습니다). 친화수가 220·284 두 줄로 찍히는 건 i가 220일 때와 284일 때 각각 조건을 만족하기 때문이고, 1~1000 사이 완전수는 6 · 28 · 496 셋뿐입니다.
생성자는 객체 생성 시 그 객체에 대해 한 번 실행되며(this(...)로 이어지면 생성자 여러 개가 차례로 실행), 이름은 클래스명과 같고 반환타입이 없다.
파라미터 있는 생성자를 직접 만들면 기본 생성자는 자동으로 생기지 않는다 → 필요하면 직접 선언.
생성자 첫 줄에는 this(...) 또는 super(...)둘 중 하나만 올 수 있다.
모든 클래스는 자동으로 Object를 상속하고, 생략된 super()는 그 Object()를 부른다.
this.필드 = 파라미터로 같은 이름의 필드와 파라미터를 구분한다.
private 필드는 getter / setter를 통해서만 접근 — 그 안에 조건 검사를 넣을 수 있는 게 캡슐화의 이점.
toString()의 기본 형태는 패키지.클래스명@해시코드(16진수), hashCode()는 같은 값을 10진수로 반환.
==는 주소 비교, equals()는 내용 비교(String 등에서 재정의된 경우). Object의 기본 equals는 주소 비교다.
문자열 리터럴은 String pool에서 공유되어 ==도 true, new String()은 새 객체라 false.
유클리드 호제법: gcd(a, b) = gcd(b, a % b), b가 0이면 a가 답. 최소공배수 = a * b / gcd.
진약수 합 sumDivisor(n) 기준: 완전수는 n == sumDivisor(n), 친화수는 서로의 진약수 합이 상대가 되는 쌍(자기 자신은 제외).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
new D4_Constructor(70)으로 만든 tv2의 color는 무엇일까?
"검정석"(오타 그대로)입니다. size만 받는 생성자는 this.size = size;만 하고 color는 건드리지 않으므로, 필드 선언 때의 초기값 "검정석"이 남습니다. 기본 생성자가 this(24, "검정색")으로 위임하듯 이 생성자도 this(size, "검정색");으로 위임하면 기본값을 한 곳에서만 관리할 수 있습니다.
D2_ClassTest에서 기본 생성자 D2_ClassTest() { this(10); } 를 통째로 지우면 어디서 문제가 생길까?
D2_ClassTestMain의 new D2_ClassTest() 줄이 컴파일 에러가 납니다. 파라미터 있는 생성자 D2_ClassTest(int number)를 직접 만들었으니 컴파일러가 기본 생성자를 더 이상 자동으로 만들어 주지 않기 때문입니다. new D2_ClassTest(30) 줄은 그대로 동작합니다.
코드 주석처럼 equals()는 hashCode로 객체를 비교할까? ot.equals(str2)가 false인 이유는?
아닙니다. D3_ObjectTest는 equals를 재정의하지 않았으니 Object의 기본 equals, 즉 ==와 같은 주소 비교가 돌고, ot와 str2는 다른 객체라 false입니다. String의 equals는 글자를 하나씩 비교하도록 재정의되어 있습니다. hashCode는 HashMap·HashSet이 칸을 빨리 고르려고 쓰는 보조값이고, 지켜야 할 규칙은 "equals가 true인 두 객체는 hashCode도 같아야 한다"입니다.
💼 실무·코딩테스트에서는오늘의 private 필드 + getter/setter는 실무에서 DTO(데이터를 담아 옮기는 클래스)의 기본 모양으로 매일 씁니다 — 나중에 게시판의 HkDto가 바로 이 형태입니다. setter에서 값을 검증하거나, 바뀌면 안 되는 값은 setter 없이 생성자로만 받는 식으로 캡슐화를 활용하고, 객체를 HashMap 키나 HashSet에 넣을 땐 equals와 hashCode를 함께 재정의합니다. 면접 단골은 "==와 equals의 차이", "equals를 재정의하면 hashCode도 재정의해야 하는 이유"입니다. 코딩테스트에서는 약수·최대공약수(유클리드 호제법)가 자주 나오는데, 약수의 합은 오늘의 divisor처럼 약수를 찾되 n/2나 √n까지만 봐서 반복을 줄이는 문제입니다.
TIP오늘 코드에서 눈여겨볼 곳 네 군데. ① D4_Constructor의 필드 초기값이 "검정석"으로 오타가 나 있는데, new D4_Constructor(70)처럼 size만 받는 생성자로 만들면 color가 그 오타 값 그대로 남습니다. 이 생성자도 this(size, "검정색");으로 위임하면 기본값을 한 곳에서만 관리할 수 있어요. ② getSize(int pw)는 "조건을 걸 수 있다"는 걸 보여주기엔 좋지만, getter의 일반 규약은 "파라미터 없이 값만 반환"입니다 — 시험에서는 public int getSize() { return size; } 형태를 기억하세요. ③ 뺄셈 방식 getGcd1(a, b)은 a나 b에 0이 들어오면 a != b가 끝나지 않아 무한 루프에 빠집니다. 나눗셈 버전 getGcd·재귀 버전 getGcd2는 b == 0에서 정상 종료하니, 실전에서는 이쪽이 안전합니다. ④ D3_ObjectTest의 주석 "equals()가 hashcode()를 이용해서 객체를 비교한다", "s.equals(s2) // hashcode로 비교"는 사실과 다릅니다. Object의 기본 equals()는 ==와 똑같이 주소를 비교하고, String의 equals()는 문자를 한 글자씩 비교합니다 — 어느 쪽도 hashCode를 쓰지 않습니다. hashCode는 HashMap·HashSet 같은 해시 자료구조가 "어느 칸에 넣을지" 빨리 고르려고 쓰는 보조 값입니다. → 🧱 14. == 와 equals의 차이
2026-08-07 · 값타입 vs 참조타입 · final 상수 · 싱글턴 패턴 · String 비교
Immutablefinalstatic finalSingleton형변환오토박싱== vs equalsStringBuilder
한 줄 요약4일차의 "참조타입은 주소를 들고 있다"를 실험으로 확인한 날 — 기본타입 파라미터는 복사본만 바뀌지만 참조타입 파라미터는 필드를 직접 건드리면 원본이 바뀐다는 걸 직접 눈으로 봤고, final로 값 변경을 막는 법(변수·배열·생성자 초기화)과 인스턴스를 하나만 만드는 싱글턴 패턴, 참조타입끼리의 형변환과 오토박싱/언박싱, 그리고 String이 == 비교에서 왜 함정인지를 리터럴과 new String()을 나란히 찍어 확인했다.
쉽게 말하면int를 메서드에 넘기는 건 사진을 복사해서 주는 것이라 상대가 사진에 낙서해도 원본은 멀쩡하지만, 객체를 넘기는 건 원본이 있는 집 주소를 알려주는 것이라 상대가 그 집(필드)을 직접 고치면 원본도 같이 바뀝니다. final은 "이 값은 이제 못 바꿔"라는 잠금이고, 싱글턴은 회사 대표번호처럼 딱 하나만 존재하도록 생성자를 잠가 버리는 것입니다.
① D1_ImmutableTest — 기본타입은 복사, 참조타입은 "주소로 직접 수정"
파일 이름은 ImmutableTest지만 실제 내용은 "메서드에 값을 넘기면 무엇이 복사되는가"입니다. 핵심 규칙 하나 — 자바는 항상 값을 복사해서 넘깁니다. 기본타입은 그 값(5)이, 참조타입은 그 객체의 주소값이 복사됩니다. 그래서 메서드 안에서 파라미터에 imTest = new D1_ImmutableTest();처럼 다른 객체를 대입해도 복사된 주소만 바뀔 뿐 원본 변수는 그대로이고, 원본에 영향을 주는 건 복사된 주소를 따라가 그 객체의 필드를 고칠 때뿐입니다. (String이 immutable이라는 이야기는 아래 ⑤에서 따로 확인합니다.)
change01(int a)는 파라미터 a에 값이 복사되므로 메서드 안에서 a = 10을 해도 원본 a는 여전히 5입니다. 반면 change02(D1_ImmutableTest imTest)는 다릅니다 — int aa = imTest.bb; aa = 15;는 꺼내온 값의 복사본만 바꾼 것이라 원본에 영향이 없지만, 바로 다음 줄 imTest.bb = 10;은 참조(주소)를 타고 들어가 원본 객체의 필드를 직접 수정합니다. 그래서 결과가 15가 아니라 10으로 나옵니다 — "참조타입을 넘기면 무조건 다 바뀐다"가 아니라 "필드에 직접 대입해야 바뀐다"는 걸 정확히 구분해야 하는 대목입니다. → 🧱 09. 기본타입 vs 참조타입
② D2_FinalTest — 참조타입 상수의 함정: "값은 바뀌는데 주소는 못 바꾼다"
public static final int[] arrayNum = {1,2,3,4};처럼 배열을 final로 선언해도 arrayNum[0] = 10;은 정상 실행됩니다(결과: [10, 2, 3, 4]). final이 막는 건 변수가 가리키는 주소를 다른 배열로 바꿔치기하는 것(arrayNum = test;, 주석 처리된 줄)이지, 그 주소 안의 내용물을 바꾸는 것까지는 못 막습니다 — 시험에서 자주 나오는 함정입니다. 한편 final int num2;는 필드 선언 시점에 값을 안 넣고 생성자에서 딱 한 번 초기화하는 패턴입니다. 이렇게 선언할 때 비워 둔 final 필드를 blank final이라 부르고, 모든 생성자가 끝나기 전에 정확히 한 번 값을 넣어야 합니다(안 넣으면 컴파일 에러). 덕분에 객체마다 다른 값을 가지면서도 그 뒤로는 바꿀 수 없습니다. → 🧱 10. final · 상수와 Wrapper 클래스
③ D3_SingletomTest — 생성자를 잠그고 대신 창구 하나만 열기
생성자를 private으로 선언하면 클래스 외부에서는 new D3_SingletomTest()를 할 수 없습니다. 대신 public static getInstance()가 객체가 아직 없을 때만 딱 한 번 생성해서(if (st == null)) 그 하나를 계속 돌려줍니다. st 필드가 static인 이유도 명확합니다 — getInstance()가 static 메서드라 객체 없이도 호출돼야 하고, static 메서드는 static 필드만 직접 건드릴 수 있기 때문입니다. main에서 getInstance()를 두 번 불러도(st, st2) 실제로는 같은 객체 하나를 가리키게 됩니다. → 🧱 11. 싱글턴 패턴
④ D3_Singleton — 참조타입 형변환과 오토박싱/언박싱
Object obj = sm;은 자식(더 구체적)에서 부모(더 포괄적)로 가는 업캐스팅이라 자동으로 되지만, 그 상태로는 obj.test()를 호출할 수 없습니다 — 형이 Object로 바뀌면서 컴파일러 눈에는 test()가 안 보이기 때문입니다. 다시 원래 타입으로 되돌리는 (D3_Singleton) obj가 다운캐스팅이고, 이걸 해야 test()가 다시 보입니다. 코드 주석은 이걸 b=(byte)200에 비유했지만 둘은 다릅니다 — 기본타입 캐스팅은 값 자체를 잘라 바꿔서 손실이 생길 수 있고(200 → -56), 참조 캐스팅은 객체는 그대로 두고 "어떤 타입으로 바라볼지"만 바꿉니다(실제 객체가 그 타입이 아니면 실행 중 ClassCastException). 아래쪽 Integer ii = a;(int → Integer, 오토박싱)와 int b = (int) obj3;(Object → int, 오토언박싱 + 캐스팅)도 같은 맥락 — 기본타입도 Object가 필요한 자리(예: List<Integer> — Integer만 담는 목록. 컬렉션·제네릭은 뒤에서 따로 배웁니다)에서는 자동으로 포장됩니다. → 🧱 20. this와 super, 참조타입 형 변환
⑤ D4_StringCompare — 리터럴은 공유, new String()은 새 집
s1 == s2(둘 다 리터럴 "java")는 true — String pool에 하나만 만들어 두고 공유하기 때문입니다. 반면 new String("java")로 만든 obj1·obj2는 내용이 같아도 obj1 == obj2가 false — Heap에 매번 새 객체가 생기기 때문입니다. equals()는 세 경우 모두 true인데, 내용을 비교하기 때문입니다. 이어서 ss += "a"를 10번 반복하는 것과 StringBuilder.append("a")를 10,000번 반복하는 것을 나란히 비교했는데, String의 +=는 매번 새 문자열을 만들어 느리고, StringBuilder는 같은 버퍼에 이어 붙여 훨씬 효율적입니다. (단 이 실험은 반복 횟수가 10번 vs 10,000번으로 달라 직접 비교가 안 되고, 찍은 hashCode()는 효율과 무관합니다 — 아래 TIP 참고.) 마지막 immutable 부분은 두 줄을 구분해서 봐야 합니다. sss = "자바"; 뒤에 s가 그대로인 건 immutable의 증거가 아닙니다 — sss 변수가 다른 문자열을 가리키게 됐을 뿐이고, 이건 String이 아닌 어떤 참조타입이라도 똑같습니다. 진짜 증거는 s.replace("j","o")입니다. 문자열을 바꾸는 메서드를 s에 직접 호출했는데도 s는 "java" 그대로이고, 바뀐 결과는 새 문자열(s4)로 돌아옵니다 — 이게 String은 immutable이라는 뜻입니다. → 🧱 12. String pool 메모리 구조 · 🧱 14. == 와 equals의 차이 · 🧱 15. StringBuilder
JAVAD1_ImmutableTest.java (수업 실습 원본)
package hk.edu20260807.day05;
public class D1_ImmutableTest {
public static void main(String[] args) {
int a = 5;
change01(a);
System.out.println("원본 a변수의 값은?" + a);
D1_ImmutableTest imTest = new D1_ImmutableTest();
change02(imTest);
System.out.println("원본 bb변수의 값은?" + imTest.bb);
}
public static void change01(int a) {
a = 10; // 받은 쪽에서 10으로 값을 변경
}
public int bb = 5; // 멤버필드
public static void change02(D1_ImmutableTest imTest) {
int aa = imTest.bb; // 실제 값을 꺼내서 저장한뒤 사용
aa = 15;
imTest.bb = 10; // 직접 주소로 접근해서 값을 변경하는 경우 ->원본이 저장됨
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
원본 a변수의 값은?5
원본 bb변수의 값은?10
a는 기본타입 파라미터라 복사본만 10으로 바뀌고 원본은 5 그대로입니다. bb는 aa = 15(복사본 수정)에는 영향받지 않지만 imTest.bb = 10(필드 직접 수정)에는 영향받아 10이 됩니다 — 15가 아니라 10이라는 점이 포인트입니다.
JAVAD2_FinalTest.java (수업 실습 원본)
package hk.edu20260807.day05;
import java.util.Arrays;
public class D2_FinalTest {
// 참조타입 상수 선언: 값변경 금지(X), 주소변경 금지(O)
public static final int[] arrayNum = { 1, 2, 3, 4 };
// 멤버필드에 static을 사용해서 상수를 정의하자
public static final int num = 100;
// 생성자를 통해서 값을 초기화하는 코드를 작성한다면 초기값 정의 안해도됨
public final int num2;
// 생성자
public D2_FinalTest(int num2) {
this.num2 = num2;
}
public static void main(String[] args) {
int a = 5; // 값이 변경될 수 있음 -> 변수
a = 15;
final int b = 5; // 값이 변경되지 않음 -> 상수(상수는 대문자로 선언)
// b = 15; // 에러발생 -> final로 선언된 변수는 값을 변경할 수 없다
// 메서드에 파라미터를 통해 값을 변경한다면?
int result1 = test01(20);
int result2 = test01(40);
// 생성자에 파라미터를 통해 값을 변경할 수 있다
D2_FinalTest ft1 = new D2_FinalTest(100);
D2_FinalTest ft2 = new D2_FinalTest(200);
arrayNum[0] = 10;
System.out.println(Arrays.toString(arrayNum));
int[] test = { 1, 2, 3, 4, 5 };
// arrayNum = test; // 에러발생 참조타입의 주소를 변경하려고 했다.
}
// 메서드에서 선언: 권장하지 않음
public static int test01(int val) {
final int aa = val;
return aa;
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
[10, 2, 3, 4]
첫 번째 원소가 10으로 바뀐 채로 출력됩니다 — final이 막은 건 arrayNum 변수가 다른 배열을 가리키게 하는 것뿐, 배열 안의 원소를 바꾸는 것까지는 막지 못합니다.
JAVAD3_SingletomTest.java (수업 실습 원본 — 싱글턴 클래스)
package hk.edu20260807.day05;
public class D3_SingletomTest {
// static을 붙인 이유는 getInstance메서드가 static이라서
private static D3_SingletomTest st;
private D3_SingletomTest() {// 외부에서 접근 못함 --> new를 못함
}
public static D3_SingletomTest getInstance() {
if (st == null) { // 객체가 생성되지 않았을때만 생성하자
st = new D3_SingletomTest();
}
return st;
}
}
package hk.edu20260807.day05;
import java.util.ArrayList;
import java.util.List;
public class D3_Singleton {
public static void main(String[] args) {
// 참조타입끼리 형변환
D3_Singleton sm = new D3_Singleton();
sm.test();
Object obj = sm; // Object 부모 객체(더 큰 개념) -> 자동 형변환
// obj.test(); //형변환되면 설계도가 바뀌기 때문에 test()를 못찾음
D3_Singleton afterSm = (D3_Singleton) obj; // b=(byte)200 같은거임
afterSm.test();// 이제 설계도에 test()가 보이므로 호출 가능
// 주의사항: 객체간에 관계가 없는 것끼리는 형변환X
// 기본타입과 참조타입 변환
int a = 10;
Object obj2 = a; // 참조타입 <--- 기본타입
Integer ii = a;// 중간 진행 과정
// Integer iii = new Integer(a);
Object obj3 = ii;
// Integer.parseInt("10");
System.out.println(obj3.toString());
int b = (int) obj3; // 언박싱해서 정수형으로 다시 사용한다.
List<Integer> list = new ArrayList<>();
list.add(10);
// list.add("가"); //자동 형변환 -> 참조타입끼리는 가능
// 싱글턴 패턴-------------------
// D3_SingletomTest st = new D3_SingletomTest(); //x
D3_SingletomTest st = D3_SingletomTest.getInstance();
D3_SingletomTest st2 = D3_SingletomTest.getInstance();
}
public void test() {
System.out.println("singleton메서드");
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
singleton메서드
singleton메서드
10
sm.test()와 다운캐스팅 후 afterSm.test()가 각각 한 번씩 같은 문구를 두 번 찍고, obj3.toString()(오토박싱된 Integer)이 10을 출력합니다. st·st2는 겉보기엔 두 줄이지만 getInstance() 안의 if (st == null) 덕분에 실제로는 같은 객체 하나를 가리킵니다.
JAVAD4_StringCompare.java (수업 실습 원본)
package hk.edu20260807.day05;
public class D4_StringCompare {
public static void main(String[] args) {
// 리터럴과 리터럴 비교
String s1 = "java";
String s2 = "java";
System.out.println((s1 == s2) + ":" + (s1.equals(s2)));
// 객체와 객체 비교
String obj1 = new String("java");
String obj2 = new String("java");
System.out.println((obj1 == obj2) + ":" + (obj1.equals(obj2)));
// 객체와 리터럴
System.out.println((s1 == obj1) + ":" + (s1.equals(obj1)));
// 메모리 효율이 안좋음 (권잘하지 않음)
String ss = "a";
for (int i = 0; i < 10; i++) {
ss += "a";
}
System.out.println("ss" + ss.hashCode());
// 이렇게 해야 효율적임
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append("a");
}
String result = sb.toString();
System.out.println("result" + result.hashCode());
// String 특징: immutable -> 값을 변경할 수 없다.
// String이 생성된 시점부터 메모리에 고정됨
String s = "java";// 원본
String sss = s;// 복사본
sss = "자바";// 값을 변경
System.out.println(s); // 원본은 그대로
String s4 = s.replace("j", "o");// 원본이 바뀌지 않는다
System.out.println(s4); // 바뀐 내용을 사용하려면 재할당받아야 한다
System.out.println(s);
}
}
앞의 true / false / false가 오늘의 핵심입니다 — 리터럴끼리는 ==도 true(pool 공유), new String()끼리는 false(서로 다른 Heap 객체), 리터럴과 객체 사이도 false. equals()는 세 줄 다 true입니다. ss·result의 해시코드 숫자 자체는 중요하지 않고, +=보다 StringBuilder가 대량 연결에 적합하다는 게 요점입니다. 마지막 세 줄에서 s는 replace 이후에도 "java" 그대로라 String이 immutable임을 다시 확인합니다.
5일차에 배운 것 — 핵심 정리
기본타입 파라미터는 값이 복사되어 원본에 영향 없음, 참조타입 파라미터는 필드에 직접 대입해야 원본이 바뀐다.
final 배열/객체는 주소 변경은 금지되지만 내용(원소·필드) 변경은 가능하다.
진짜 상수는 static final + 대문자, 인스턴스별로 다른 불변값은 final 필드 + 생성자에서 초기화.
싱글턴 패턴 = 생성자를 private으로 막고 static getInstance()가 null일 때만 생성해서 반환.
오토박싱(기본타입→Wrapper)·오토언박싱(Wrapper→기본타입)은 자동 처리되며, List<Integer>처럼 컬렉션에 기본타입을 담을 때 쓰인다.
문자열 리터럴은 String pool에서 공유되어 ==도 true, new String()은 매번 새 객체라 ==는 false.
String의equals()는 리터럴이든 new 객체든 내용(문자)을 비교한다 — String이 equals를 그렇게 재정의해 두었기 때문이고, 재정의하지 않은 클래스는 Object의 기본 equals()(= ==, 주소 비교)를 그대로 쓴다.
String은 immutable — replace()같은 메서드는 원본을 바꾸지 않고 새 문자열을 반환한다(재할당 필요).
반복 연결에는 += 대신 StringBuilder.append()가 효율적이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
change02의 첫 줄에 imTest = new D1_ImmutableTest(); 를 넣으면 main의 imTest.bb는 몇이 찍힐까?
5입니다. 자바는 항상 값을 복사해서 넘기므로 파라미터 imTest에는 원본 객체의 주소값이 복사되어 있을 뿐입니다. 거기에 새 객체를 대입하면 복사된 주소만 새 객체를 가리키게 되고, 이어지는 imTest.bb = 10;은 새 객체의 필드를 바꿉니다. main의 변수는 여전히 원래 객체(bb = 5)를 가리킵니다.
arrayNum은 static final인데 왜 arrayNum[0] = 10; 은 되고 arrayNum = test; 는 에러일까?
final이 잠그는 것은 변수에 들어 있는 값 — 참조타입이면 주소입니다. arrayNum = test;는 다른 배열의 주소로 바꿔치기하려는 것이라 컴파일 에러지만, arrayNum[0] = 10;은 주소는 그대로 두고 그 주소에 있는 배열의 내용을 고치는 것이라 막지 못합니다. 그래서 출력이 [10, 2, 3, 4]입니다.
sss = "자바"; 뒤에 s가 "java" 그대로인 것은 String이 immutable이라는 증거일까?
아닙니다. 그건 sss 변수가 다른 문자열을 가리키게 됐을 뿐이고, String이 아닌 어떤 참조타입이라도 원본 변수는 똑같이 그대로입니다. 진짜 증거는 s.replace("j", "o")입니다 — s에 직접 바꾸는 메서드를 불렀는데도 s는 "java" 그대로이고, 바뀐 결과는 새 문자열(s4)로 돌아옵니다.
💼 실무·코딩테스트에서는실무에서 싱글턴은 직접 만들기보다 스프링이 대신 관리합니다 — 스프링 빈은 기본이 싱글턴이라 서비스·DAO 객체 하나를 모든 요청이 함께 씁니다. 그래서 빈의 필드에 요청마다 다른 값을 저장하면 다른 사용자의 요청과 값이 섞이는 버그가 납니다. 상수는 static final로, 반복 연결은 StringBuilder로, 문자열 비교는 항상 equals로 하는 것이 기본 습관입니다. 면접에서는 "자바는 Call by Value인가", "String·StringBuilder·StringBuffer의 차이(StringBuffer는 동기화됨)", "싱글턴 구현"이 단골이고, 코딩테스트에서는 숫자 문자열과 영단어처럼 s = s.replace(...)로 다시 담지 않아 답이 안 바뀌는 실수가 자주 나옵니다.
TIPD1_ImmutableTest.change02를 시험 직전에 다시 보세요. aa = imTest.bb;로 꺼낸 aa는 int 지역변수라 이후 아무리 바꿔도(aa = 15) 원본과 무관합니다. 원본이 바뀌는 건 오직 imTest.bb = 10;처럼 객체의 참조를 타고 들어가 필드에 직접 대입할 때뿐입니다. "참조타입을 넘기면 항상 원본이 바뀐다"고 외우면 이 문제에서 틀립니다 — "필드에 직접 대입했는가"가 진짜 기준입니다. 하나 더 — D3_Singleton의 주석 처리된 줄 // list.add("가"); //자동 형변환 -> 참조타입끼리는 가능은 틀린 설명입니다. List<Integer>에는 Integer만 넣을 수 있어서 주석을 풀면 컴파일 에러가 납니다. String과 Integer는 상속 관계가 없으므로 "참조타입끼리"라도 자동 형변환되지 않습니다(제네릭이 바로 이런 실수를 컴파일 시점에 막아 주는 장치입니다). 마지막으로 D4_StringCompare의 효율 비교를 제대로 하려면 같은 횟수로 시간을 재야 합니다 — 예를 들어 두 방식 모두 10,000번 반복하고 앞뒤로 long start = System.currentTimeMillis(); … System.out.println(System.currentTimeMillis() - start + "ms");를 찍어 보면 += 쪽이 눈에 띄게 오래 걸리는 걸 확인할 수 있습니다(정확한 ms는 컴퓨터마다 다름). 수업 코드처럼 hashCode()를 찍는 건 결과 문자열의 해시값일 뿐 속도와는 관계가 없습니다.
한 줄 요약지금까지 배운 생성자 오버로딩 · private+getter · 분기(if~else)를 사칙연산 계산기라는 실전 예제로 묶어 본 날 — 덧셈·뺄셈·곱셈·나눗셈을 클래스 4개로 쪼개고 연산자 문자열로 알맞은 클래스를 골라 실행하는 구조를 만들었고, charAt · indexOf · lastIndexOf · substring · replace 같은 String 메서드를 실전 검색·치환 예제로 총정리했으며, 마지막엔 "5+10" 같은 문자열을 indexOf로 연산자 위치를 찾아 substring으로 피연산자를 잘라내 실제로 계산하는 미니 계산기를 Scanner로 반복 입력받아 완성했다.
쉽게 말하면오늘은 그동안 배운 문법 부품을 계산기라는 하나의 물건으로 조립해 본 날입니다. 덧셈 담당, 뺄셈 담당처럼 연산자마다 클래스를 따로 만들고, "어떤 연산인지"를 보고 알맞은 담당자를 불러 쓰는 if~else 분기가 사장님(Compare 클래스) 역할을 합니다. 문자열 파싱은 "5+10"이라는 종이 쪽지에서 숫자만 오려내는 것과 같은데, 가위질할 위치(indexOf)를 먼저 찾고 그 자리를 기준으로 잘라냅니다(substring).
① D1_CalculatorA~D — 연산자마다 클래스를 따로, 결과는 private + getter로
덧셈(A)·뺄셈(B)·나눗셈(C)·곱셈(D) 네 클래스가 구조는 완전히 동일하고 a() 메서드 안의 연산자 한 글자만 다릅니다. 공통 패턴은 두 가지입니다. 하나, 생성자 오버로딩 — 기본 생성자 D1_CalculatorA()는 this(10, 5);로 파라미터 있는 생성자에 위임해서 기본값 10·5를 세팅합니다(4일차 D4_Constructor의 this(24, "검정색") 복습). 둘, result는 private으로 감추고 setter도 두지 않아서 외부에서 임의로 값을 넣지(대입) 못하게 막고 — 결과는 오직 a() 계산으로만 바뀝니다 — getResult()를 거쳐야만 결과를 꺼낼 수 있습니다 — 4일차 D4_Constructor의 캡슐화가 그대로 실전에 쓰인 예입니다. → 🧱 07. 생성자와 생성자 오버로딩 · 🧱 04. 접근 제한자 4가지
② D1_CalculatorCompare — 연산자 문자열로 알맞은 클래스를 골라 실행
calculator(num1, num2, cal)은 cal.equals("+")부터 순서대로 검사해서 맞는 연산 클래스를 그 자리에서 생성 → 실행 → 결과만 꺼내오는 구조입니다. 예를 들어 "+"면 D1_CalculatorA를 만들어 calA.a()를 실행하고 calA.getResult()로 결과를 받아 자기 필드 this.result에 저장합니다. 어디에도 해당하지 않으면 "잘못된 연산자입니다."를 출력하는 else 기본 분기도 빠뜨리지 않았습니다. 문자열 비교는 반드시 .equals()를 쓴 것도 눈여겨볼 점입니다(==였다면 5일차에서 본 대로 위험합니다). → 🧱 03. 메서드의 구조
charAt(idx)는 인덱스 하나의 문자(char) 한 개를 반환하고, indexOf/lastIndexOf는 앞에서부터/뒤에서부터 찾아 처음 등장한 위치(없으면 -1)를 반환합니다 — indexOf("문자열", 시작, 끝)처럼 검색 구간을 지정할 수도 있습니다(단 이 3인자 indexOf는 JDK 21부터 생긴 메서드라 그보다 낮은 버전에서는 컴파일 에러가 납니다). substring(idx)는 그 인덱스부터 끝까지, substring(s, e)는 e 직전까지만 잘라냅니다. replace(old, new)는 원본을 바꾸지 않고 새 문자열을 반환하므로 재할당이 필요합니다(5일차 String immutable과 같은 맥락). search() 메서드는 이 넷을 한 번에 실전 예제로 묶어 검색어 존재 확인 → 반복 검색하며 인덱스·추출 출력 → 개수 세기 → ###로 일괄 치환까지 이어 붙였습니다. → 🧱 13. String 주요 메서드
④ D3_Calculator — "5+10" 문자열을 파싱해서 실제로 계산하기
paramInt(s, cal)이 핵심입니다. s.indexOf(cal)로 연산자의 위치를 찾은 뒤, s.substring(0, idx)로 앞부분(첫 번째 숫자), s.substring(idx + cal.length())로 뒷부분(두 번째 숫자)을 잘라 Integer.parseInt()로 숫자로 바꿔 멤버필드에 저장합니다. 여기서 idx + s.length()가 아니라 idx + cal.length()를 더해야 한다는 주석이 오늘의 함정 포인트 — 연산자 뒤부터 잘라야지 문자열 전체 길이를 더하면 안 됩니다. calcu(s)는 +, -, *, /를 차례로 검사해 맞는 사칙연산 메서드(a·b·c·d)를 호출하고, D3_CalculatorMain은 Scanner로 "0"을 입력할 때까지 계속 반복해서 문자열을 계산합니다. → 🧱 13. String 주요 메서드 · ☕ Scanner
package hk.edu20260810.day06;
//덧셈기능의 클래스
public class D1_CalculatorA {
// 계산할 값 2개를 저장할 멤버필드
public int num1;
public int num2;
// 계산 결과를 저장할 멤버필드
// 계산 결과는 중요한 값이기 때문에 외부에서 쉽게 접근하지 못하게 하자
private int result;
// default 생성자: 초기값 기본값으로 10,5로 셋팅하고 싶다면
public D1_CalculatorA() {
this(10, 5);
}
// 생성자 오버로딩(파라미터 2개)
public D1_CalculatorA(int num1, int num2) {
this.num1 = num1;
this.num2 = num2;
}
// 기능 정의: 덧셈연산기능 구현
public void a() {
this.result = this.num1 + this.num2;
}
// getter 메서드:private 필드에 접근하여 값을 가져오기 위해 선언
public int getResult() {
return result;
}
}
// ※ 이 블록은 파일 두 개를 이어 붙인 것입니다. public class 하나당 파일 하나이므로
// 복사할 때는 클래스별로 나눠 저장하세요(같은 package 선언을 각 파일 맨 위에).
package hk.edu20260810.day06;
public class D1_CalculatorCompare {
// 은닉화(캡슐화)
private int result; // 연산결과
public int getResult() {
return result;
}
// 연산할 때 필요한 값: 연산한 숫자2개, 연산자: "+,-,/,*"
public void calculator(int num1, int num2, String cal) {
// 분기형태로 실행 -> if문 ~ else
if (cal.equals("+")) {
D1_CalculatorA calA = new D1_CalculatorA(num1, num2);
calA.a(); // 덧셈연산 실행
this.result = calA.getResult(); // 은닉화: getter메서드 통해 결과 가져오기
} else if (cal.equals("-")) {
D1_CalculatorB calB = new D1_CalculatorB(num1, num2);
calB.a(); // 뺄셈연산 실행
this.result = calB.getResult();
} else if (cal.equals("*")) {
D1_CalculatorD calD = new D1_CalculatorD(num1, num2);
calD.a(); // 곱셈연산 실행
this.result = calD.getResult();
} else if (cal.equals("/")) {
D1_CalculatorC calC = new D1_CalculatorC(num1, num2);
calC.a(); // 나눗셈연산 실행
this.result = calC.getResult();
} else {
System.out.println("잘못된 연산자입니다.");
}
}
}
// ===== 여기부터 D1_CalculatorMain.java (별도 파일) =====
public class D1_CalculatorMain {
public static void main(String[] args) {
int num1 = 50;
int num2 = 20;
String[] cals = { "+", "-", "*", "/" };
D1_CalculatorCompare calcu = new D1_CalculatorCompare();
for (int i = 0; i < cals.length; i++) {
String cal = cals[i];
calcu.calculator(num1, num2, cal);
System.out.printf("%d와 %d의 %s 연산 결과 : %d \n", num1, num2, cal, calcu.getResult());
}
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
50와 20의 + 연산 결과 : 70
50와 20의 - 연산 결과 : 30
50와 20의 * 연산 결과 : 1000
50와 20의 / 연산 결과 : 2
배열 cals를 순서대로 돌면서 매번 calculator()를 호출 → 그때그때 알맞은 연산 클래스가 만들어지고 결과가 this.result에 갱신됩니다. 나눗셈 결과 2는 int끼리 나눈 것이라 소수점이 버려진 값입니다(50÷20=2.5 → 2).
JAVAD2_StringMethodTest.java + D2_StringMethonMain.java (수업 실습 원본 — 기사 문자열만 생략)
// ※ 파일 두 개를 이어 붙인 블록입니다 — 복사할 때는 클래스별로 나눠 저장하세요.
package hk.edu20260810.day06;
public class D2_StringMethodTest {
// String 주요 메서드 연습
// 1.문자 하나를 반환
// "문자열에서 문자 하나를 인덱스로 추출하는 기능" -> charAt(int index)
// "Hello Java".charAt(6) -> 'J' 반환
public String sTest01(String s, int idx) {
char c = s.charAt(idx); // char타입의 값 표현 -> ''
String ss = c + ""; // 문자열로 변환
String ss2 = String.valueOf(c); // 문자열로 변환
// 예시) 숫자형태의 문자열 -> 숫자형태
int i = Integer.parseInt("100"); // 문자열을 숫자로 바꾸는거임
return ss;
}
// 2.문자열에서 특정문자의 위치(인덱스) 반환 : indexOf()
// "Hello Java".indexOf('J') -> 6
// "ABCD" -> "BC" 검색 -> "ABCD".indexOf("BC") -> 1
// 반환값은 해당 단어의 첫번째 인덱스를 반환
// 종류: indexOf(), lastIndexOf() --> 차이점: 앞에서부터, 뒤에서부터 검색방향
// 인덱스는 그대로 12345.. 이렇게 가는거임. 인덱스가 43210 이렇게 되는게 아님.
// indexOf("A") --. "ABCACB" --> 0반환하고 종료
public String sTest02(String s) {
int s1 = s.indexOf("AB");
int s2 = s.indexOf("C", 2); // 검색 시작 인덱스 지정
int s3 = s.indexOf("DE", 2, 5); // 시작, 끝 인덱스 지정
int s4 = s.lastIndexOf("AB"); // 뒤에서부터 검색
int s5 = s.lastIndexOf("C", 2); // 뒤에서부터 시작 인덱스 지정
int s6 = s.lastIndexOf("DE", 5); // 뒤에서부터 시작 인덱스 지정 (lastIndexOf는 매개변수를 2개까지만 지원)
System.out.printf("%d, %d, %d, %d, %d, %d \n", s1, s2, s3, s4, s5, s6);
// 해당 단어가 존재하는지 확인하는 용도로도 많이 사용된다.(없으면 -1반환)
if (s.indexOf("A") != -1) {
System.out.println("a가 존재합니다.");
} else {
System.out.println("a가 존재하지 않습니다.");
}
return s;
}
// 3. 문자열의 길이 반환: length()
// 4. 문자열의 내용 변환: replace("원본","새로운 내용")
public void sTest04() {
String s = "자바프로그래밍자바웹개발자,자바스크립트";
s.replace("자바", "python"); // 원본(s) 의 내용이 바뀌지 않는다(immutable)
s = s.replace("자바", "python"); // 변경한 내용을 변수에 재할당해야 바뀐다
System.out.println(s);
}
// 5.문자열을 추출하기: substring()
// substring(idx), substring(sIdex,eIdex) --> eIdex 전까지만 포함
public void sTest05() {
String s = "Hello Java";
String s1 = s.substring(6); // 인덱스6부터 추출
String s2 = s.substring(0, 5); // 인덱스0부터 4까지만 추출
System.out.printf("%s, %s \n", s1, s2);
}
// 예제: 검색어가 존재하는지 판단해서 존재한다면 추출·출력하고, "###"으로 바꾼 뒤
// 계속 존재하는지 반복 확인한다.
public void search(String str) {
// ※ 카드에서는 기사 본문을 줄인 생략본입니다. 원본 문자열에는 "KB증권"이 5번 들어 있고,
// 아래 실행 결과는 원본 전체 문자열로 돌린 결과입니다.
String s = "KB증권은 10일 삼성전자에 대해 ...(중략)... KB증권 리서치본부장은 ...";
// 1. 해당 검색어 존재 여부 판단
if (s.indexOf(str) == -1) {
System.out.println("검색어가 존재하지 않습니다.");
return;
}
int count = 0;
int searchIndex = 0; // 검색 시작 위치
// 2~4. indexOf(str, searchIndex)를 활용하여 반복 검색
while (true) {
int index = s.indexOf(str, searchIndex);
if (index == -1) {
break; // 더 이상 검색어가 없으면 반복 종료
}
System.out.println("검색어 인덱스: " + index);
System.out.println("추출된 검색어: " + s.substring(index, index + str.length()));
count++;
searchIndex = index + str.length(); // 다음 검색 시작 인덱스 업데이트
}
System.out.println("검색된 개수: " + count + "개");
// 5. replace()로 일괄 변환 후 출력
System.out.println("\n=== ### 변환 결과 ===");
System.out.println(s.replace(str, "###"));
}
}
// ===== 여기부터 D2_StringMethonMain.java (별도 파일) =====
public class D2_StringMethonMain {
public static void main(String[] args) {
D2_StringMethodTest st = new D2_StringMethodTest();
System.out.println(st.sTest01("ABCDE", 2));
st.sTest02("ABCDEF");
st.search("KB증권");
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
C
0, 2, 3, 0, 2, 3
a가 존재합니다.
검색어 인덱스: 0
추출된 검색어: KB증권
검색어 인덱스: 32
추출된 검색어: KB증권
검색어 인덱스: 101
추출된 검색어: KB증권
검색어 인덱스: 131
추출된 검색어: KB증권
검색어 인덱스: 203
추출된 검색어: KB증권
검색된 개수: 5개
=== ### 변환 결과 ===
###은 10일 삼성전자에 대해 "연간 주주환원 규모가 ###최대 200조원에 달할 것"이라며 "확신의 매수 구간"이라고 판단했다. 목표주가는 60만원을 유지했다.
김동원 ### 리서치본부장은 "조만간 발표될 것으로 예상되는### 연간 주주환원 규모는 최소 100조원에서 최대 200조원으로 추정된다"며 "기존(9조8000억원)보다 10배 이상 커질 ### 것으로 전망된다"고 강조했다.
sTest01("ABCDE", 2)는 인덱스 2의 문자 C를 반환합니다. sTest02의 0, 2, 3, 0, 2, 3은 순서대로 indexOf("AB")=0, indexOf("C",2)=2, indexOf("DE",2,5)=3, lastIndexOf("AB")=0, lastIndexOf("C",2)=2, lastIndexOf("DE",5)=3입니다("ABCDEF" 기준). search("KB증권")은 실제 기사 본문에서 "KB증권"이 5번 등장하는 걸 찾아 각 인덱스를 출력하고, 마지막엔 전부 ###로 바꿔 찍습니다. 위 코드의 기사 문자열은 줄여 쓴 생략본(KB증권 2번)이라, 인덱스·개수·변환 결과는 저장소의 원본 파일(기사 전체, KB증권 5번)로 실행한 출력입니다.
// ※ 파일 두 개를 이어 붙인 블록입니다 — 복사할 때는 클래스별로 나눠 저장하세요.
package hk.edu20260810.day06;
public class D3_Calculator {
// 연산을 하기 위한 숫자 2개를 저장할 맴버필드
public int num1;
public int num2;
// "5+10" 같은 문자열에서 정수를 추출하여 num1,num2에 저장
// s는"5+10" , cal은 "+","/","-","*" 사칙연산자
public void paramInt(String s, String cal) {
if (s.indexOf(cal) != -1) {
num1 = Integer.parseInt(s.substring(0, s.indexOf(cal)));
// s.length()가 아니라 연산자 길이(cal.length())를 더해야 함!
num2 = Integer.parseInt(s.substring(s.indexOf(cal) + cal.length()));
}
}
public int a(int a, int b) {
System.out.println("덧셈을 실행합니다.");
return a + b;
}
public int b(int a, int b) {
System.out.println("뺄셈을 실행합니다.");
return a - b;
}
public int c(int a, int b) {
System.out.println("곱셈을 실행합니다.");
return a * b;
}
public int d(int a, int b) {
System.out.println("나눗셈을 실행합니다.");
return a / b;
}
// 입력받은 값에 해당하는 사칙연산 메서드를 실행
public void calcu(String s) {
if (s.indexOf("+") != -1) {
paramInt(s, "+");
System.out.println(a(num1, num2));
} else if (s.indexOf("-") != -1) {
paramInt(s, "-");
System.out.println(b(num1, num2));
} else if (s.indexOf("*") != -1) {
paramInt(s, "*");
System.out.println(c(num1, num2));
} else if (s.indexOf("/") != -1) {
paramInt(s, "/");
System.out.println(d(num1, num2));
} else {
System.out.println("올바른 사칙연산식이 아닙니다.");
}
}
}
// ===== 여기부터 D3_CalculatorMain.java (별도 파일) =====
package hk.edu20260810.day06;
import java.util.Scanner; // 원본 파일에 있는 import(카드 발췌에서 빠졌던 줄) — 없으면 Scanner를 찾지 못해 컴파일 에러
public class D3_CalculatorMain {
public static void main(String[] args) {
D3_Calculator calcu = new D3_Calculator();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("계산할 문자열을 입력해주세요 (종료: 0) : ");
String s = scanner.nextLine();
if (s.equals("0")) {
System.out.println("프로그램을 종료합니다.");
break;
}
calcu.calcu(s);
}
}
}
실행 결과입력 "5+10" → "5*20" → "0" 순으로 넣고 돌려 본 출력 보기 먼저 예측 → 펼쳐서 확인
계산할 문자열을 입력해주세요 (종료: 0) :
덧셈을 실행합니다.
15
계산할 문자열을 입력해주세요 (종료: 0) :
곱셈을 실행합니다.
100
계산할 문자열을 입력해주세요 (종료: 0) :
프로그램을 종료합니다.
"5+10"은 indexOf("+")로 위치 1을 찾아 num1=5·num2=10으로 나눈 뒤 15를 계산하고, "5*20"은 같은 방식으로 100을 계산합니다. "0"을 입력하면 while(true) 반복문이 break로 종료됩니다.
6일차에 배운 것 — 핵심 정리
기능이 같은 형태로 여러 개 필요하면(사칙연산) 클래스를 나누고 공통 구조(필드·생성자·getter)를 통일한다.
this(10, 5)처럼 기본 생성자가 파라미터 있는 생성자에 위임해 기본값을 한 곳에서 관리한다.
중요한 계산 결과는 private으로 감춰 외부에서 임의로 대입하지 못하게 하고 getter로만 꺼내게 하는 것이 캡슐화의 실전 활용이다.
문자열 비교는 .equals()로 한다 — ==는 리터럴이 아니면 위험하다(5일차 복습).
charAt(idx)는 문자 하나, indexOf/lastIndexOf는 앞/뒤에서 찾은 위치(없으면 -1), substring(a,b)는 b 직전까지만 자른다.
replace()는 원본을 바꾸지 않고 새 문자열을 반환한다 — 재할당해야 반영된다.
문자열 파싱 공식: 연산자 위치 = s.indexOf(cal) → 앞부분 = substring(0, 위치), 뒷부분 = substring(위치 + cal.length())(전체 길이가 아니라 연산자 길이를 더한다).
Scanner.nextLine()과 while(true)로 종료 조건("0")을 만날 때까지 반복 입력받는 구조를 만든다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
paramInt에서 num2를 자를 때 cal.length() 대신 s.length()를 더하면 "5+10"에서 무슨 일이 생길까?
s.indexOf("+")는 1, s.length()는 4라서 substring(5)가 되는데, 길이 4인 문자열에서 5번 칸부터 자를 수는 없어 StringIndexOutOfBoundsException이 납니다. 연산자 바로 뒤(1 + 1 = 2)부터 잘라야 "10"이 나옵니다 — 더해야 하는 것은 연산자의 길이입니다.
D3_CalculatorMain에 "5 + 10"처럼 공백을 넣어 입력하면 어떻게 될까?
NumberFormatException으로 프로그램이 멈춥니다. "+"를 기준으로 자르면 앞은 "5 ", 뒤는 " 10"인데 Integer.parseInt는 공백이 섞인 문자열을 숫자로 바꾸지 못하기 때문입니다. 자르기 전에 s = s.replace(" ", "");로 공백을 지우거나 자른 조각에 trim()을 해야 합니다 — 사용자 입력은 변환 전에 정리·검증하는 것이 기본입니다.
D1_CalculatorMain의 나눗셈 결과가 2.5가 아니라 2인 이유는? 2.5를 얻으려면?
num1·num2가 둘 다 int라 int끼리의 나눗셈은 소수점을 버린 몫(50 / 20 = 2)을 줍니다. 2.5를 얻으려면 한쪽을 먼저 실수로 바꿔 (double) num1 / num2로 계산하고, 결과를 담는 result 필드와 getResult()의 반환타입도 double로 바꿔야 합니다. (double) (num1 / num2)처럼 묶으면 이미 2가 된 뒤에 바꾸는 것이라 2.0이 됩니다.
💼 실무·코딩테스트에서는계산 결과를 private + getter로 감춘 것처럼, 실무에서도 DTO·엔티티의 필드를 감추고 정해진 메서드로만 바꾸게 설계합니다. 웹에서 사용자가 보낸 값(request.getParameter)은 전부 문자열이라 오늘의 indexOf·substring·parseInt와 입력 검증(공백 제거·형식 확인·NumberFormatException 처리)을 매일 쓰게 되고, 연산자마다 if~else로 클래스를 고르는 구조는 뒤에서 배우는 인터페이스·다형성으로 더 깔끔하게 정리됩니다. 코딩테스트에서는 문자열 자르기·변환이 단골입니다 — 문자열을 정수로 바꾸기(parseInt가 부호까지 읽는 문제), 핸드폰 번호 가리기(바꿀 범위를 길이 - 4로 정확히 잡는 문제)가 그 예입니다.
TIPD3_Calculator.paramInt의 num2 = Integer.parseInt(s.substring(s.indexOf(cal) + cal.length())); 한 줄이 오늘의 시험 단골 함정입니다. 숫자로 확인해 보면 — s = "5+10"일 때 s.indexOf("+")는 1, cal.length()는 1, s.length()는 4입니다. 올바르게 1 + 1 = 2부터 자르면 "10"이 나오지만, 실수로 1 + 4 = 5를 넣으면 길이 4인 문자열에서 5번 칸부터 자르라는 뜻이 되어 StringIndexOutOfBoundsException이 납니다(시작 위치가 길이와 딱 같으면 예외 대신 빈 문자열 ""이 나와 parseInt에서 NumberFormatException). 연산자가 여러 글자(예: "**")여도 cal.length()를 더하면 그대로 맞습니다. 그리고 D1_CalculatorCompare처럼 클래스를 매번 새로 new하는 구조는 클래스 하나(D3_Calculator)에 메서드 4개를 몰아넣은 구조보다 객체가 더 많이 생기지만, 대신 연산자별로 클래스를 나눠 관리하기 쉽다는 트레이드오프가 있다는 것도 함께 기억해 두세요. 수업 파일 주의 — ① D2_StringMethodTest의 주석 "인덱스는 그대로 12345.."는 0부터(01234…)가 맞습니다. 요점은 "lastIndexOf로 뒤에서 찾아도 인덱스 번호는 앞에서부터 센다"입니다. ② sTest02는 대문자 "A"를 검사하는데 출력 문구는 "a가 존재합니다."라서 헷갈립니다 — indexOf는 대소문자를 구분하므로 "a"로 검사했다면 "ABCDEF"에서 -1이 나옵니다. ③ 이름 규칙이 두 파일에서 반대입니다 — D1_CalculatorC는 나눗셈·D1_CalculatorD는 곱셈인데, D3_Calculator의 메서드는 c()가 곱셈·d()가 나눗셈입니다. 글자만 보고 짐작하지 말고 안의 연산자를 확인하세요.
한 줄 요약6일차의 문자열 메서드를 개미수열(Look-and-say)이라는 실전 문제로 다시 써먹고, 배열을 리터럴·new·크기만 선언 세 가지 방식으로 만드는 법과 얕은 복사(주소만 복사)와 깊은 복사(내용을 통째로 복사)의 차이를 b = a 대입 하나로 직접 확인한 날. 2차원 배열의 length는 "바깥 배열 길이"와 "안쪽 배열 길이"가 따로라는 것과, 2차원 ↔ 1차원 배열 변환 공식도 코드로 정리했다.
쉽게 말하면b = a;는 원본 사진을 새로 뽑아 주는 게 아니라 같은 사진이 걸린 액자를 하나 더 다는 것입니다 — 액자(b) 속 사진을 고치면 원본(a) 사진도 같이 바뀝니다(얕은 복사). 진짜 사진을 한 장 더 인화하려면 arraycopy()나 직접 루프를 돌려 내용물을 새 자리에 옮겨 담아야 합니다(깊은 복사).
① D1_Ant — 개미수열, indexOf 없이 charAt만으로
6일차엔 indexOf로 검색어를 찾았다면, 오늘은 ant.charAt(j) == ant.charAt(j+1)로 바로 옆 문자와 비교하며 같은 문자가 몇 번 반복되는지(count) 세고, 문자가 바뀌는 순간 "문자+개수"를 이어 붙이는 구조입니다. ant = ant + " "로 맨 끝에 임시 공백을 하나 더해 마지막 문자도 반드시 "문자가 바뀌는 순간" 처리되게 만든 부분이 포인트입니다. 실습과제 7번과 같은 문제로, 이미 정리된 개념 카드가 있습니다. → 🧪 7번 개미수열
② D2_ArrayTest — 배열을 만드는 세 가지 방법
(a) 리터럴: int[] a = {1,2,3,4,5,6};처럼 선언과 동시에 초기화(int[] a; 다음 줄에 a = {…};처럼 선언과 초기화를 다른 줄로 나눌 수 없음). (b) new: b2 = new int[]{1,2,3,4,5};로 나중에 초기화. (c) 크기만 선언: new int[5]처럼 길이(칸 수)만 정하면 int는 자동으로 0으로 채워집니다. 생성자에서도 this(3)으로 위임해 배열 크기를 정하는 오버로딩(6일차 패턴 그대로)을 다시 썼습니다. → 🧱 16. 배열 — 선언 3가지 방법
③ 얕은 복사와 깊은 복사 — b = a vs arraycopy()
b = a; 다음 b[0] = 10;을 하면 a도 함께 바뀝니다 — b가 새 배열이 아니라 a와 같은 주소를 가리키는 이름표이기 때문입니다(얕은 복사). 진짜로 별개의 배열을 만들려면 for문으로 값을 하나씩 옮기거나, System.arraycopy(원본, 시작, 대상, 시작, 길이)를 씁니다. 수업 주석은 arraycopy()와 clone()을 "깊은 복사"라고 불렀지만 정확히는 둘 다 새 배열을 만들고 칸의 값을 그대로 옮기는 얕은 복사입니다 — arraycopy()는 기본타입 배열뿐 아니라 어떤 배열에도 쓸 수 있고, 1차원 기본타입 배열이면 칸에 값 자체가 들어 있어 결과가 깊은 복사처럼 동작합니다. 반면 객체 배열·2차원 배열은 칸에 든 것이 주소라 안쪽 객체·배열을 원본과 공유합니다. → 🧱 17. 얕은 복사 · 깊은 복사
④ 2차원 배열의 length와 1차원 ↔ 2차원 변환 공식
aa.length는 행(바깥 배열)의 개수, aa[0].length는 그 행 안의 열 개수입니다 — 서로 다른 것을 잰다는 걸 헷갈리면 안 됩니다. 2차원 → 1차원은 dd[i*열개수+j] = bb[i][j], 반대로 1차원 → 2차원은 ee[i/열개수][i%열개수] = dd[i] 공식으로 변환합니다 — 몫은 행, 나머지는 열이라는 게 핵심입니다.
JAVAD1_Ant.java (수업 실습 원본)
package hk.edu20260811.day07;
import java.util.Scanner;
public class D1_Ant {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.println("개미 문자열을 입력해주세요 : ");
String ant = sc.nextLine();
System.out.println("몇번째까지? : ");
int total = sc.nextInt();
// String ant = "1";
// int total = 10;
int count = 1;
for (int i = 0; i < total; i++) {
String next = "";
ant = ant + " ";
for (int j = 0; j < ant.length() - 1; j++) {
if (ant.charAt(j) == ant.charAt(j + 1)) {
count++;
} else {
next = next + ant.charAt(j) + count;
count = 1;
}
}
ant = next;
System.out.println(ant);
}
}
}
실행 결과입력 "1" → "5"(5번째까지) 넣고 돌려 본 출력 보기 먼저 예측 → 펼쳐서 확인
이 수업의 개미수열 규칙은 한 줄로 "같은 숫자 묶음마다 숫자를 먼저, 개수를 뒤에 적는다"입니다. "1" → "11"(1이 1개) → "12"("11"은 1이 2개) → "1121"(1이 1개, 2가 1개) → "122111" → ... 로 이어집니다. 매 줄이 직전 줄을 "숫자+개수"로 읽어 만든 결과라는 걸 손으로 짚어 보면 규칙이 보입니다. 참고로 표준 Look-and-say 수열은 반대로 개수를 먼저, 숫자를 뒤에(1 → 11 → 21 → 1211) 적으니 다른 자료와 비교할 때 헷갈리지 마세요. 입력값("1", "5")은 실행할 때 자동으로 넣어 준 것이라 출력에서 입력 줄은 생략했습니다(Eclipse 콘솔에서 직접 치면 입력한 값도 화면에 보입니다).
JAVAD2_ArrayTest.java (수업 실습 원본)
package hk.edu20260811.day07;
import java.util.Arrays;
public class D2_ArrayTest {
// 멤버필드에 선언
public int[] test;
public int[][] test2;
public D2_ArrayTest() {
// test = new int[3]; // 정의
// test2 = new int[3][3]; // 2차원배열 정의
this(3); // 자기자신의 생성자 호출
}
public D2_ArrayTest(int n) { // 생성자 오버로딩
test = new int[n]; // 정의
test2 = new int[n][n]; // 2차원배열 정의
}
public D2_ArrayTest(int m, int n) { // 생성자 오버로딩
test = new int[m]; // 정의
test2 = new int[m][n]; // 2차원배열 정의
}
public static void main(String[] args) {
// 선언 방법
// 1. 리터럴방식: 기본타입처럼 선언
int[] a = { 1, 2, 3, 4, 5, 6 }; // 바로 선언과 동시에 초기화를 해야 함
int[] b = null;
b = a;// 주소복사(얕은 복사)
b[0] = 10;
System.out.println(Arrays.toString(a));
// 2. new를 사용하는 정의
int[] b2;
b2 = new int[] { 1, 2, 3, 4, 5 };
// 선언과 정의(자릿수) -> 자동초기화 지원(int면 0으로 초기화됨)
int[] b3 = new int[5];
for (int i = 0; i < b3.length; i++) {
b3[i] = i + 1;
}
// sort(): 사전식, 크기순 정렬 모두 지원
Arrays.sort(b3);// mutable하기 때문에 원본이 바로 바뀜
String s = "ss"; // String은 immutable하기 때문에 replace를 사용하면 새로운 String이 생성됨
s = s.replace("s", "p"); // 다시 대입해야 원본이 바뀜
// 깊은복사
int[] e = new int[] { 1, 2, 3, 4, 5, 6 };
for (int i = 0; i < b3.length; i++) {
e[i] = b3[i];
}
// 깊은복사 기능 : System.arraycopy() 단, 값의 타입이 기본타입일 경우
int[] f = new int[5];
// (원본대상배열,복사할 시작위치, 복사받을 배열, 시작위치, 복사할 길이)
System.arraycopy(e, 0, f, 0, f.length);
// 깊은 복사하는 방법 2가지
// - arraycopy()
// - clone() : Object클래스의 메서드
// 2차원배열 선언하기
int[][] aa = { { 1, 2, 3 }, { 4, 5, 6 } }; // 2행 3열
int[][] bb = new int[][] { { 1, 2, 3 }, { 4, 5, 6 } }; // 2행 3열
int[][] cc = new int[2][3];
cc[0] = new int[] { 1, 2, 3 };
cc[1] = new int[] { 4, 5, 6 };
// 배열의 길이값
System.out.println("aa의 배열의 길이:" + aa.length);
System.out.println("aa의 배열의 내부 배열의 길이:" + aa[0].length);
for (int i = 0; i < aa.length; i++) {
for (int j = 0; j < aa[i].length; j++) {
System.out.println(aa[i][j]);
}
System.out.println();
}
// 배열 변환
// 2차원 배열 --> 1차원 배열 변환
int[] dd = new int[bb.length * bb[0].length];
// i*col +j = 0*3+0 = 0, 0*3+1 = 1, 0*3+2 = 2
// 1*3+0 = 3, 1*3+1 = 4, 1*3+2 = 5 -> 0,1,2,3,4,5
for (int i = 0; i < bb.length; i++) {
for (int j = 0; j < bb[i].length; j++) {
dd[i * bb[0].length + j] = bb[i][j];
}
}
System.out.println(Arrays.toString(dd));
// 1차원배열 --> 2차원배열
// 공식: [i/col][i%col]
int[][] ee = new int[2][3];
int col = ee[0].length;
for (int i = 0; i < dd.length; i++) {
ee[i / col][i % col] = dd[i];
}
for (int i = 0; i < ee.length; i++) {
System.out.println(Arrays.toString(ee[i]));
}
}
}
첫 줄 [10, 2, 3, 4, 5, 6]이 오늘의 핵심 — b[0]=10만 했는데 a까지 10으로 바뀝니다(얕은 복사). aa는 2행 3열이라 aa.length=2, aa[0].length=3입니다. 마지막 세 줄은 2행 3열 bb를 1차원 [1,2,3,4,5,6]으로 폈다가 다시 2차원 [1,2,3]·[4,5,6]으로 되돌린 결과로, 왕복해도 원래 모양 그대로 돌아온다는 걸 보여줍니다.
arr = arr2;는 주소만 복사(원본과 같은 배열을 공유) — 새 배열이 필요하면 arraycopy(), clone(), 직접 루프로 복사한다. 단 이 셋도 칸의 값만 옮기므로 1차원 기본타입 배열에서만 깊은 복사처럼 동작하고, 객체·2차원 배열에서는 안쪽을 공유하는 얕은 복사다.
개미수열은 바로 옆 문자와 비교하며 개수를 세다가, 문자가 바뀌는 순간 "문자+개수"를 이어 붙이는 구조다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D2_ArrayTest에서 b[0] = 10;만 했는데 Arrays.toString(a)가 [10, 2, 3, 4, 5, 6]으로 나오는 이유는?
b = a;는 새 배열을 만들지 않고 a에 든 주소값만 b에 복사하기 때문입니다(엄밀히는 참조 복사). 두 변수가 같은 배열 하나를 가리키니 b로 고친 칸을 a로 읽어도 바뀐 값이 보입니다. 따로 놀게 하려면 new로 새 배열을 만든 뒤 값을 옮겨야(루프·arraycopy()·clone()) 합니다.
D1_Ant에서 ant = ant + " "; 줄을 지우면 출력이 어떻게 될까?
마지막 묶음이 결과에서 빠집니다. "문자+개수"는 옆 문자와 달라지는 순간(else)에만 붙는데, 맨 끝 문자는 비교할 다음 문자가 없어 그 순간이 오지 않기 때문입니다. 입력이 "1"이면 안쪽 루프가 한 번도 돌지 않아 첫 줄부터 빈 문자열이 찍힙니다 — 끝에 붙인 공백은 "마지막에도 문자가 바뀌게 만드는" 보초 역할입니다.
int[][] copy = bb.clone(); 다음 copy[0][0] = 99;를 하면 bb[0][0]은 몇일까?
99로 같이 바뀝니다.clone()은 바깥 배열만 새로 만들고 칸의 값을 그대로 옮기는데, 2차원 배열의 칸에 든 것은 안쪽 행 배열의 주소라 두 배열이 같은 행을 공유합니다(얕은 복사). 수업 주석이 clone()·arraycopy()를 "깊은 복사"라 부른 것은 1차원 기본타입 배열에서만 맞는 말이고, 2차원을 완전히 떼어 내려면 copy[i] = bb[i].clone();처럼 행마다 다시 복사해야 합니다.
💼 실무·코딩테스트에서는실무에서는 객체가 들고 있는 배열을 getter로 그대로 내주면 밖에서 원본을 고칠 수 있어서, Arrays.copyOf()나 clone()으로 사본을 돌려주는 방어적 복사를 씁니다 — 오늘의 "주소 공유"를 모르면 생기지 않을 버그가 생깁니다. 코딩테스트에서는 2차원 배열을 행·열로 도는 문제(행렬의 덧셈)가 기본이고, 격자 문제에서 [i / 열개수][i % 열개수] 변환이 자주 쓰입니다. 면접에서는 "얕은 복사와 깊은 복사의 차이"를 2차원·객체 배열 예로 설명할 수 있으면 됩니다.
TIPArrays.sort(b3)처럼 배열을 정렬하는 메서드는 원본을 바로 바꿉니다(mutable) — 5·6일차에서 본 String.replace()(원본 불변, 재할당 필요)와 정반대라 자주 헷갈립니다. "이 메서드가 원본을 바꾸는가, 새 걸 반환하는가"를 항상 구분해서 외우세요: 배열·StringBuilder는 원본을 바꾸고, String은 항상 새 걸 반환합니다.
한 줄 요약7일차까지 배운 생성자 오버로딩·캡슐화·배열을 로또 번호 생성·저장·당첨 비교라는 여러 클래스가 협업하는 실전 미니 프로젝트로 완성했고, 뒤이어 자바 OOP의 다음 챕터인 상속(extends)을 시작했다 — 부모·자식 생성자 호출 순서, super(...), 메서드를 재정의(오버라이딩)하는 법, 그리고 부모 타입 하나로 여러 자식을 참조하는 다형성의 첫 예제를 D2_Parent/D2_Child로 직접 만들어 봤다.
쉽게 말하면로또 실습은 공(D1_Lotto) → 상자(D1_LottoStore에 여러 장 보관) → 심사위원(Compare가 당첨 비교)처럼 역할을 나눈 것이고, 상속은 "부모의 것을 그대로 물려받고, 마음에 안 드는 부분만 새로 고쳐 쓰는 것"입니다. super()는 자식이 태어나기 전에 "부모님 먼저 태어나셔야죠"라고 부르는 절차고, 오버라이딩은 물려받은 능력을 자식 버전으로 덮어쓰는 것입니다.
① D1_Lotto — 생성자에서 곧바로 초기화 로직 실행하기
D1_Lotto()가 this(6)으로 위임하면 D1_Lotto(int n) 생성자 안에서 this.lots = new int[n]; makeLotto();가 실행됩니다 — 객체가 만들어지자마자 번호 6개가 자동으로 채워지는 구조입니다. makeLotto()는 isSame()으로 중복 검사를 하며 배열이 다 찰 때까지 반복하고, getLots()가 이 배열을 꺼내 줍니다. 다만 필드 lots가 public이라 이건 캡슐화가 아닙니다 — 밖에서 getLots()를 거치지 않고 lotto.lots[0] = 99;처럼 바로 고칠 수 있으니까요(실제로 D1_LottoCompare는 storeLotto[i].lots로 직접 접근합니다). private int[] lots;로 바꾸면 그때 4~6일차의 private+getter 캡슐화가 완성됩니다. → 🧪 8번 로또 · 🧱 07. 생성자와 생성자 오버로딩
② D1_LottoStore · Compare · CompareT — 클래스 여러 개가 역할을 나눠 협업
D1_LottoStore는 D1_Lotto[] lottos 배열에 로또 객체 여러 개를 담아 관리하고, D1_LottoCompare는 추첨 로또 하나와 LottoStore를 필드로 들고 있다가 checkLotto()에서 번호를 하나하나 대조합니다. D1_LottoCompareT는 같은 일을 하되 중복 검사(isSame)와 등수 판정(lottoResult)을 D1_LottoUtil의 static 메서드로 분리해 재사용했습니다(주석 처리된 예전 코드가 그 흔적입니다) — 같은 기능이라도 "누가 들고 있을 필요 없는 기능은 static 유틸로 뽑아낸다"는 설계 감각입니다. 두 방식의 핵심 비교 부분은 아래 "핵심 발췌" 코드 블록에 실었습니다.
③ D2_Parent / D2_Child — extends, 생성자 호출 순서, super()
class D2_Child extends D2_Parent로 상속을 선언합니다. 자식 객체를 생성하면 부모 생성자가 먼저 실행되고 그다음 자식 생성자가 실행됩니다. 이 순서는 super를 쓰든 안 쓰든 항상 강제되고, D2_Child(int a)의 첫 줄 super(a);가 정하는 건 부모의 생성자 중 어느 것(여기선 D2_Parent(int a))을 부를지뿐입니다. 아무것도 안 쓰면 컴파일러가 부모의 기본 생성자를 자동 호출하는데, D2_Parent에 파라미터 있는 생성자가 있어도 기본 생성자를 같이 선언해 뒀기 때문에 문제없이 동작합니다. 하나 주의 — D2_Parent(int a)는 메시지만 찍고 this.a = a;를 하지 않아서, super(5)로 넘긴 5는 그냥 버려지고 필드 a는 0 그대로입니다. 값을 받았으면 필드에 저장하는 줄까지 써야 의미가 있습니다. → 🧱 18. OOP 3대 개념과 상속 · 🧱 07. 생성자와 생성자 오버로딩
④ 오버라이딩과 다형성 — 부모 타입 하나로 자식을 참조하기
D2_Child가 @Override로 parentMethod()와 toString()을 다시 정의했습니다. D2_Parent child2 = new D2_Child();처럼 부모 타입 변수로 자식 객체를 참조하면, child2.parentMethod()를 호출해도 실제 객체(자식)에서 재정의한 버전이 실행됩니다 — 이게 다형성입니다. 다만 부모 타입에는 자식에서만 만든 메서드(childMethod())는 안 보이므로, 쓰려면 (D2_Child) child2로 다운캐스팅해야 합니다. test(D2_Parent p) 메서드가 p instanceof D2_Child로 실제 타입을 확인한 뒤 안전하게 다운캐스팅하는 패턴도 보여줍니다. (아래 링크의 VMI는 Virtual Method Invocation — "변수 타입이 아니라 실행할 때 실제 객체를 보고 그 객체의 메서드를 부르는 방식"을 가리키는 말입니다.) → 🧱 19. 오버라이딩과 VMI · 🧱 20. 참조타입 형 변환
JAVAD1_Lotto.java + D1_LottoStore.java (수업 실습 원본)
// ※ 파일 두 개를 이어 붙인 블록입니다. public class 하나당 파일 하나이므로
// 복사할 때는 클래스별로 나눠 저장하세요(같은 package 선언을 각 파일 맨 위에).
package hk.edu20260812.day08;
//Loto 클래스 --> 로또 한장
public class D1_Lotto {
// 번호 6개가 저장될 배열
public int[] lots;
public D1_Lotto() {
this(6);
}
// 생성자 오버로딩
public D1_Lotto(int n) {
this.lots = new int[n];
makeLotto();// 객체생성하자마자 번호6개 추가하기
}
// 1~45까지 숫자를 랜덤하게 생성하는 메서드
public int makeBall() {
return (int) (Math.random() * 45) + 1;
}
// 배열에 로또 번호 6개를 넣어주는 메서드
public void makeLotto() {
int count = 0;
while (count < lots.length) {
int b = makeBall(); // 랜덤숫자 생성
if (!isSame(lots, b)) {
lots[count++] = b;
}
}
}
// 중복된 번호가 있는지 판별하는 메서드
public boolean isSame(int[] a, int b) {
boolean isS = false;
for (int i = 0; i < a.length; i++) {
if (a[i] == b) {
isS = true;
break;
}
}
return isS;
}
public int[] getLots() {
return lots;
}
}
// ===== 여기부터 D1_LottoStore.java (별도 파일) =====
public class D1_LottoStore {
public D1_Lotto[] lottos;
public D1_LottoStore() {
this(5);
}
public D1_LottoStore(int count) {
this.lottos = new D1_Lotto[count];
for (int i = 0; i < lottos.length; i++) {
lottos[i] = new D1_Lotto();
}
}
public D1_Lotto[] getLottoStore() {
return lottos;
}
}
makeBall()이 Math.random()으로 매번 다른 번호를 뽑기 때문에 실행할 때마다 결과가 달라집니다 — 여기 적힌 숫자는 한 번 실행해 본 예시입니다. 5장 중 마지막 한 장만 2개 일치해서 5등, 나머지는 전부 꽝으로 나온 것도 이번 실행에 한정된 우연입니다. 등수는 수업에서 단순화한 규칙(6개=1등, 5개=2등, 4개=3등, 3개=4등, 2개=5등 — D1_LottoCompare의 switch와 D1_LottoUtil.lottoResult()가 같은 규칙)이고, 실제 로또는 3개 일치가 5등이며 2등은 5개+보너스 번호입니다. 또 이 출력은 맨 위 두 클래스만으로 나오는 게 아니라, 카드에 싣지 않은 D1_LottoMain(맨 위 6줄 — 로또 1장 + 5장 출력)과 D1_LottoCompare.checkLotto()("=== 추첨번호 ===" 이하, 비교 부분은 위 발췌)를 함께 실행한 결과입니다(이어서 실행되는 D1_LottoCompareT.compareBall()의 출력은 생략). 전체 코드는 아래 실습 파일 링크에서 볼 수 있습니다.
// ※ 파일 세 개를 이어 붙인 블록입니다 — 복사할 때는 클래스별로 나눠 저장하세요.
package hk.edu20260812.day08;
public class D2_Parent {
public int a;
public D2_Parent() {
System.out.println("부모생성자(default)");
}
public D2_Parent(int a) {
System.out.println("부모생성자(오버로딩)");
}
public void parentMethod() {
System.out.println("부모의 메서드:" + getClass());
}
@Override
public String toString() {
return "D2_Parent [a=" + a + "]";
}
}
// ===== 여기부터 D2_Child.java (별도 파일) =====
//문법: extends , 다중상속X
public class D2_Child extends D2_Parent {
public String s = "나는 자식의 맴버필드 값이야";
public D2_Child() {
this(5);
System.out.println("자식생성자(default)");
}
public D2_Child(int a) {
super(a);// 부모가 먼저 생성된후 자식이 생성된다.
System.out.println("자식생성자(오버로딩)");
}
public void childMethod() {
parentMethod();// 부모메서드를 자식의메서드처럼 사용가능
System.out.println("자식클래스에서 정의된 메서드:" + getClass());
}
// 부모의 메서드를 자식이 재정의하는 기능
@Override
public void parentMethod() {
System.out.println("부모메서드를 자식이 재정의한 메서드");
}
@Override
public String toString() {
return s;
}
}
// ===== 여기부터 D2_ChildMain.java (별도 파일) =====
public class D2_ChildMain {
public static void main(String[] args) {
D2_Child child = new D2_Child();// 자식의 타입으로 자식을 생성
child.childMethod();
child.parentMethod();
D2_Parent child2 = new D2_Child();// 부모의 타입으로 자식을 생성
System.out.println("-----------------");
child2.parentMethod();// 오버라이딩한 메서드(자식에서 구현한 메서드가 실행됨)
D2_Child child3 = (D2_Child) child2;// 큰타입을 작은타입으로 변환
child3.childMethod();
D2_Parent p = new D2_Parent();
System.out.println(p.toString());
System.out.println(child2);
test(child);
test(child2);
test(child3);
}
// OOP 3대 개념 중 다형성
public static void test(D2_Parent p) {
p.parentMethod();// 오버라이딩되어 있어서 자식의 메서드가 실행
if (p instanceof D2_Child) {
D2_Child ch = (D2_Child) p;
ch.childMethod();
}
}
}
맨 처음 "부모생성자(오버로딩)" → "자식생성자(오버로딩)" → "자식생성자(default)" 순서가 오늘의 핵심입니다 — D2_Child()가 this(5)로 위임하고, 그 D2_Child(int a)가 super(a)로 부모를 먼저 실행시킨 뒤에야 자기 자신의 출력을 찍기 때문입니다. child2.parentMethod()가 계속 "자식이 재정의한" 버전을 실행하는 것도 눈여겨보세요 — 변수 타입은 부모(D2_Parent)여도 실제 객체가 자식이면 자식 버전이 실행됩니다(다형성).
8일차에 배운 것 — 핵심 정리
생성자에서 곧바로 초기화 로직(makeLotto() 등)을 실행하면 객체 생성과 동시에 준비가 끝난다.
여러 기능이 서로 얽혀 있다면 클래스를 역할별로 나누고(로또 한 장 / 여러 장 보관 / 비교) 필요하면 공통 로직은 static 유틸 클래스로 뽑아낸다.
상속은 extends로 선언하며 자바는 다중 상속을 지원하지 않는다(부모는 하나만).
객체 생성 시 부모 생성자가 먼저, 자식 생성자가 나중에 실행되며, super(...)로 어느 부모 생성자를 부를지 명시할 수 있다.
자식이 부모 메서드를 @Override로 재정의하면, 부모 타입 변수로 참조해도 실제 객체(자식)의 버전이 실행된다(다형성).
부모 타입 변수에는 자식에서만 새로 만든 메서드가 보이지 않는다 — 쓰려면 자식 타입으로 다운캐스팅.
다운캐스팅 전에는 instanceof로 실제 타입을 확인하는 것이 안전하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
new D2_Child()의 출력이 "부모생성자(오버로딩) → 자식생성자(오버로딩) → 자식생성자(default)" 순서인 이유는?
D2_Child()의 첫 줄 this(5)가 먼저 D2_Child(int a)로 넘어가고, 그 첫 줄 super(a)가 D2_Parent(int a)를 실행합니다. 부모 생성자가 끝나야 D2_Child(int a)의 나머지 출력이, 그것이 끝나야 D2_Child()의 출력이 찍힙니다. this(...)로 위임한 생성자에는 super()가 따로 들어가지 않아 부모 생성자는 딱 한 번만 실행됩니다.
super(5)로 5를 넘겼는데 D2_Parent의 필드 a가 여전히 0인 이유는?
D2_Parent(int a)가 메시지만 찍고 this.a = a;를 하지 않기 때문입니다. 매개변수 a는 생성자 안에서만 사는 지역변수라 생성자가 끝나면 사라지고, 필드 a는 int 기본값 0 그대로 남습니다. super(...)는 "어느 부모 생성자를 부를지"를 정할 뿐, 값을 저장하는 것은 그 생성자 몸체가 할 일입니다.
test(new D2_Parent())를 호출하면 무엇이 찍힐까? if (p instanceof D2_Child) 검사를 빼면?
객체를 만들 때 "부모생성자(default)"가 먼저 찍히고, 진짜 부모 객체라서 p.parentMethod()는 부모 버전("부모의 메서드:class …D2_Parent")이 실행되고, instanceof가 false라 childMethod()는 건너뜁니다. 검사를 빼고 (D2_Child) p로 바로 바꾸면 컴파일은 되지만 실행 중 ClassCastException이 납니다 — 다운캐스팅은 실제 객체가 그 자식일 때만 성공하기 때문입니다.
💼 실무·코딩테스트에서는실무에서는 test(D2_Parent p)처럼 부모 타입으로 받아 두는 코드가 결제 수단·알림 방식이 늘어나도 호출부를 안 고치게 해 주는 기본 기법이고, 로또의 public int[] lots처럼 필드를 열어 두는 것은 코드 리뷰에서 바로 지적받습니다(private+getter). 코딩테스트에서는 isSame() 같은 중복 검사를 매번 배열을 훑는 대신 HashSet으로 처리하는 경우가 많습니다(폰켓몬). 면접에서는 "오버로딩과 오버라이딩의 차이"와 "생성자 호출 순서"가 단골 질문입니다.
TIPD2_ChildMain의 test(D2_Parent p)를 다시 보세요. 이 메서드는 딱 한 번만 정의됐는데도child·child2·child3 세 가지 서로 다른 변수(타입은 제각각이지만 실제로는 전부 D2_Child 객체)를 다 받아 처리했습니다. 파라미터를 부모 타입으로 선언해 두면, 그 부모를 상속한 어떤 자식이 오더라도 같은 코드로 처리할 수 있다는 것 — 이게 다형성을 실전에서 쓰는 이유입니다. 나중에 자식 클래스가 늘어나도 test() 메서드는 고칠 필요가 없습니다.
한 줄 요약8일차의 D2_Parent/D2_Child 장난감 예제를 고객 등급 시스템(Customer → VIPCustomer)이라는 실전 도메인으로 옮겨 protected가 왜 필요한지, super.필드로 부모 필드를 초기화하는 법, 부모의 계산 로직을 자식이 확장해서 오버라이딩하는 패턴을 배웠고, 이어서 달력 출력을 세 가지 방식(직접 구현 · java.util.Calendar · java.time.LocalDate)으로 구현하며 옛날 API와 최신 API의 차이를 비교했다.
쉽게 말하면protected는 "가족(자식 클래스)에게만 열어주는 방"입니다 — 완전히 잠그면(private) 자식도 못 들어오고, 활짝 열면(public) 아무나 들어오니, 그 중간을 쓴 겁니다. VIP 고객은 일반 고객이 하던 계산(보너스 적립)에 할인 계산을 하나 더 얹은 것뿐입니다. 수업 코드는 부모의 적립 공식을 자식 메서드에 한 줄 그대로 복사한 뒤 할인을 붙였는데, 더 좋은 방법은 super.calcPrice(price)로 부모 계산을 그대로 불러 쓰고 그 위에 할인만 얹는 것입니다(아래 ②).
① D1_Customer — 왜 private이 아니라 protected인가
주석에 정확히 적혀 있습니다 — private은 상속을 해줘도 자식이 접근할 수 없어서 불편하고, protected는 같은 패키지 안의 모든 클래스 + 다른 패키지에 있는 자식 클래스에서 접근 가능합니다. 그래서 customerID·customerGrade·bonusPoint 등을 전부 protected로 선언해, 자식 클래스인 D1_VIPCustomer가 super.customerGrade = "VIP";처럼 직접 접근해 값을 바꿀 수 있게 열어 뒀습니다. calcPrice(price)는 bonusPoint += price * bonusRatio;로 가격을 계산할 때마다 보너스를 누적하는 공통 로직입니다. 그런데 bonusPoint는 int, price * bonusRatio는 double인데 왜 1일차처럼 강제 형변환 없이 컴파일될까요? += 같은 복합 대입 연산자 안에는 자동 형변환이 들어 있기 때문입니다 — bonusPoint += x;는 bonusPoint = (int)(bonusPoint + x);와 같습니다(이때 소수점은 경고 없이 버려집니다). 같은 걸 bonusPoint = bonusPoint + price * bonusRatio;로 풀어 쓰면 1일차에 본 대로 "int + double = double"이라 컴파일 에러가 납니다. → 🧱 04. 접근 제한자 4가지
② D1_VIPCustomer — 부모 계산을 가져와 자식 로직을 얹기
calcPrice(price)를 오버라이딩해서 super.bonusPoint += price * super.bonusRatio;로 부모와 똑같이 보너스는 적립하면서, 반환값은 price - (price * saleRatio)로 할인까지 적용합니다. 다만 여기서 super.필드는 부모의 계산을 가져온 게 아니라 필드에 접근한 것뿐이고, 적립 공식 자체는 부모 코드를 복사해 온 것입니다 — 나중에 부모의 적립 방식이 바뀌면 자식도 따로 고쳐야 합니다. 부모 계산을 진짜로 재사용하는 정석은 super.메서드()를 부르는 것 — int p = super.calcPrice(price); return (int) (p - p * saleRatio);처럼 부모 로직(보너스 적립)을 그대로 실행하고 그 위에 할인만 얹습니다(아래 "개선 예시" 코드 블록).
생성자도 같습니다. 수업 코드는 부모 필드를 super.customerID·super.customerGrade·super.bonusRatio로 생성자에서 직접 세팅했는데(이때도 생략된 super()가 먼저 실행돼 부모 생성자가 SILVER·0.01을 넣은 뒤에 VIP·0.05로 덮어쓰는 것입니다), 정석은 첫 줄에서 super(customerID, customerName);로 부모의 파라미터 있는 생성자를 불러 ID·이름은 부모에게 맡기고, 자식은 달라지는 등급·적립률·할인율만 세팅하는 것입니다. 이건 4~6일차에 본 "같은 클래스 안에서 this(...)로 위임"의 상속 버전이라고 보면 됩니다. → 🧱 18. OOP 3대 개념과 상속 · 🧱 19. 오버라이딩과 VMI
③ D1_CustomerMain — 부모 타입 vs 자식 타입으로 생성한 차이
customerJung은 new D1_Customer(...)로 부모 클래스 객체를 만들었으니 일반 고객 계산이, customerKim은 new D1_VIPCustomer(...)로 자식 클래스 객체를 만들었으니 VIP 계산이 실행됩니다. 기준은 변수 타입이 아니라 new로 만든 실제 객체입니다 — D1_Customer c = new D1_VIPCustomer(...);처럼 부모 타입 변수에 담아도 VIP의 calcPrice()가 실행됩니다(다형성). 둘 다 toString()이 다르게 재정의되어 있어(부모는 "...보너스점수는 N점 입니다.", 자식은 "...고객님의...") 출력 문구로도 어느 클래스가 실행됐는지 구분할 수 있습니다.
④ D2_Calendar — 직접 구현 vs Calendar vs LocalDate
직접 구현은 "1년 1월 1일부터 오늘까지 며칠이 지났는가"를 dates()로 누적 계산하고, 그 값을 7로 나눈 나머지로 1일의 요일을 계산하는 방식입니다(윤년마다 2월 마지막 날이 다르므로 leap/plain 배열 두 개로 분기). java.util.Calendar는 그 자체가 추상 클래스(몸통 없는 메서드를 포함해 그대로는 객체를 만들 수 없고, 자식 클래스가 완성해야 쓰는 클래스 — 뒤에서 따로 배웁니다)라 new가 안 되고 Calendar.getInstance()로 얻어야 하며, getActualMaximum()·get(Calendar.DAY_OF_WEEK)가 윤년 계산을 대신 해 줍니다. 여기서 숫자 규칙 두 가지가 핵심입니다 — 월은 0부터(0 = 1월, 7 = 8월)라 cal.set(year, month - 1, 1)처럼 1을 빼서 넣고, DAY_OF_WEEK는 1(일요일)~7(토요일)이라 1일 앞 공백 수는 dayOfWeek - 1입니다(직접 구현·LocalDate 버전은 일요일이 0이라 -1이 없음). LocalDate(JDK 8+)는 lengthOfMonth()·getDayOfWeek()로 더 짧게 같은 일을 합니다 — 세 가지 모두 같은 8월 달력을 만들어 낸다는 것이 이 실습의 핵심 확인 포인트입니다. → 🧪 11번 달력
// ※ 파일 두 개를 이어 붙인 블록입니다. public class 하나당 파일 하나이므로
// 복사할 때는 클래스별로 나눠 저장하세요(같은 package 선언을 각 파일 맨 위에).
package hk.edu20260813.day09;
public class D1_Customer {
// public > protected > default > private
// private은 상속을 해줄 수 없어서 자식에서 사용하기 불편함
// -> protected로 선언하면 상속이 가능, 같은 패키지 안에서는 사용가능
protected int customerID;
protected String customerName;
protected String customerGrade;
protected int bonusPoint;
protected double bonusRatio;
public D1_Customer() {
customerGrade = "SILVER";
bonusRatio = 0.01;
}
public D1_Customer(int customerID, String customerName) {
this.customerID = customerID;
this.customerName = customerName;
customerGrade = "SILVER";
bonusRatio = 0.01;
}
// 보너스 적립률 계산해서 보너스 포인트 추가
public int calcPrice(int price) {
bonusPoint += price * bonusRatio;
return price;
}
@Override
public String toString() {
return customerName + "님의 등급은 " + customerGrade + "보너스점수는 " + bonusPoint + "점 입니다.";
}
// getter/setter 생략
}
// ===== 여기부터 D1_VIPCustomer.java (별도 파일) =====
public class D1_VIPCustomer extends D1_Customer {
private int agentID; // 담당 상담원 ID
private double saleRatio; // 할인율
public D1_VIPCustomer() {
super.customerGrade = "VIP";
super.bonusRatio = 0.05;
this.saleRatio = 0.1;
}
public D1_VIPCustomer(int customerID, String customerName, int agentID) {
super.customerID = customerID;
super.customerName = customerName;
super.customerGrade = "VIP";
super.bonusRatio = 0.05;
this.saleRatio = 0.1;
this.agentID = agentID;
}
// 부모에서는 보너스 적립률만 계산하는 기능
// -> 자식에서는 할인률도 계산하는 기능이 추가
// -> 자식에서 기능을 재정의하자 : 오버라이딩
@Override
public int calcPrice(int price) {
super.bonusPoint += price * super.bonusRatio;
return (int) (price - (price * saleRatio)); // int로 강제 형변환
}
@Override
public String toString() {
return customerName + "고객님의 등급은 " + customerGrade
+ "보너스점수는 " + bonusPoint + "점 입니다.";
}
}
JAVAD1_CustomerMain.java (수업 실습 원본 — 아래 출력을 만드는 실행 쪽)
package hk.edu20260813.day09;
public class D1_CustomerMain {
public static void main(String[] args) {
// 부모의 타입으로 부모를 생성함
D1_Customer customerJung = new D1_Customer(10001, "정우성");
customerJung.bonusPoint = 1000;
int price = customerJung.calcPrice(15000);
System.out.println(customerJung.toString());
// 자식의 타입으로 자식을 생성
D1_VIPCustomer customerKim = new D1_VIPCustomer(10002, "김연아", 100);
customerKim.bonusPoint = 10000;
int price2 = customerKim.calcPrice(100000);
System.out.println(customerKim);
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
정우성님의 등급은 SILVER보너스점수는 1150점 입니다.
김연아고객님의 등급은 VIP보너스점수는 15000점 입니다.
시작 점수 1000·10000은 D1_CustomerMain이 customerJung.bonusPoint = 1000;처럼 직접 넣은 값입니다(Main이 같은 패키지라 protected 필드에 바로 접근할 수 있음). 정우성은 bonusPoint = 1000에서 시작해 calcPrice(15000)으로 15000 * 0.01 = 150이 더해져 1150이 됐습니다. 김연아는 bonusPoint = 10000에서 calcPrice(100000)으로 100000 * 0.05 = 5000이 더해져 15000이 됩니다 — VIP는 적립률(0.05)이 일반(0.01)보다 5배 높다는 게 숫자로 확인됩니다.
JAVAD1_VIPCustomer 개선 예시 (수업 코드 아님 — super로 부모 생성자·메서드 재사용)
public D1_VIPCustomer(int customerID, String customerName, int agentID) {
super(customerID, customerName); // ID·이름 세팅은 부모 생성자에 맡김(첫 줄)
customerGrade = "VIP"; // 달라지는 것만 자식이 세팅
bonusRatio = 0.05;
this.saleRatio = 0.1;
this.agentID = agentID;
}
@Override
public int calcPrice(int price) {
int p = super.calcPrice(price); // 부모 로직(보너스 적립)을 그대로 실행 — 공식 복사 X
return (int) (p - p * saleRatio); // 그 위에 할인만 얹기
}
JAVAD2_Calendar.java (수업 실습 원본 — 핵심 메서드만 발췌)
package hk.edu20260813.day09;
public class D2_Calendar {
private static final int[] leap = { 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
private static final int[] plain = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
public boolean isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}
// 1년1월1일부터 해당 연도 전까지의 경과일
public int dates(int year) {
int tot = 0;
for (int i = 1; i < year; i++) {
tot += isLeapYear(i) ? 366 : 365;
}
return tot;
}
// 전년도 경과일 + 전월까지의 경과일
public int dates(int year, int month) {
int tot = dates(year);
for (int i = 1; i < month; i++) {
tot += isLeapYear(year) ? leap[i - 1] : plain[i - 1];
}
return tot;
}
public int dates(int year, int month, int date) {
return dates(year, month) + date;
}
public void calendarPrint(int year, int month) {
System.out.println(year + "년\t" + month + "월");
System.out.println("일\t월\t화\t수\t목\t금\t토");
int dayOfWeek = dates(year, month, 1) % 7; // 1일의 요일 -> 앞쪽 공백수
for (int i = 0; i < dayOfWeek; i++) System.out.print("\t");
int lastDay = isLeapYear(year) ? leap[month - 1] : plain[month - 1];
for (int i = 1; i <= lastDay; i++) {
System.out.print(i + "\t");
if ((i + dayOfWeek) % 7 == 0) System.out.println();
}
}
// java.util.Calendar를 이용한 버전
public void calendarApiPrint(int year, int month) {
// Calendar -> new 객체생성 X --> 추상클래스, getInstance()로 얻어옴
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.set(year, month - 1, 1); // 0월~11월로 계산함
int lastDay = cal.getActualMaximum(java.util.Calendar.DAY_OF_MONTH);
int dayOfWeek = cal.get(java.util.Calendar.DAY_OF_WEEK); // 요일: 1~7 관리
// 공백수 구한다면 dayOfWeek-1
System.out.println(year + "년\t" + month + "월");
System.out.println("일\t월\t화\t수\t목\t금\t토");
// 공백 출력
for (int i = 0; i < dayOfWeek - 1; i++) {
System.out.print("\t");
}
for (int i = 1; i <= lastDay; i++) {
System.out.print(i + "\t");
if ((i + dayOfWeek - 1) % 7 == 0) {// 현재날짜가 토요일인지 확인
System.out.println();
}
}
System.out.println("\n");
}
// JDK 8부터 등장: LocalDate 버전
public void localDateCalendarPrint(int year, int month) {
java.time.LocalDate firstDay = java.time.LocalDate.of(year, month, 1); // 월에 -1 불필요
int lastDay = firstDay.lengthOfMonth(); // 윤년도 자동 계산됨
int dayOfWeek = firstDay.getDayOfWeek().getValue() % 7; // [일:0, 월:1, ..., 토:6]
// ... 위와 같은 방식으로 출력
}
}
실행 결과전체 12개월 × 2가지 구현(직접 구현 · Calendar) 중 2026년 8월(직접 구현)만 발췌 + "살아온 일수" 먼저 예측 → 펼쳐서 확인
2026년 8월
일 월 화 수 목 금 토
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
나의 살아온 일수:16519
2026년 8월 1일이 토요일이라 첫 줄 앞에 6칸 공백이 들어갑니다. 원본 main은 calendarPrint와 calendarApiPrint로 12개월씩 찍고, localDateCalendarPrint는 정의만 돼 있고 호출하지 않습니다. cal.localDateCalendarPrint(2026, 8);을 추가해 돌려 보면 세 가지 방식 모두 같은 8월 달력을 만들어 냅니다 — 계산 방법은 다르지만 결과가 같다는 걸 직접 비교해 확인할 수 있습니다. 1981-05-22생 기준 2026-08-13까지의 "살아온 일수"는 16519일입니다.
9일차에 배운 것 — 핵심 정리
protected는 같은 패키지 전체 + 다른 패키지의 자식 클래스에서 접근 가능 — private은 상속해도 자식이 못 쓰는 문제를 해결한다.
자식 생성자에서 super.필드 = 값;으로 부모 필드 값을 바꿀 수 있다. 단 부모 생성자를 건너뛰는 게 아니다 — 첫 줄에 생략된 super()가 항상 먼저 실행되어 부모 기본 생성자가 SILVER·0.01을 넣고, 그 뒤에 VIP·0.05로 덮어쓰는 것이다.
자식이 오버라이딩한 메서드 안에서 super.메서드()로 부모의 로직을 재사용할 수 있다(super.필드는 값에 접근할 뿐, 부모 계산을 재사용하는 건 아니다). 생성자에서는 super(…)로 부모 생성자에 초기화를 맡긴다.
bonusPoint += price * bonusRatio;(int += double)는 복합 대입에 자동 형변환이 들어 있어 컴파일되지만, bonusPoint = bonusPoint + price * bonusRatio;는 강제 형변환 없이는 에러다.
어떤 calcPrice()가 실행될지는 변수 타입이 아니라 new로 만든 실제 객체가 정한다(다형성) — new D1_Customer(...)면 일반 계산, new D1_VIPCustomer(...)면 D1_Customer c = new D1_VIPCustomer(...);처럼 부모 타입 변수에 담아도 오버라이딩된 VIP 로직이 실행된다.
Calendar는 추상 클래스라 new 불가 — Calendar.getInstance()로 얻는다. LocalDate(JDK 8+)가 더 짧고 안전한 최신 API다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D1_VIPCustomer(10002, "김연아", 100) 생성자에는 super(...)가 한 줄도 없는데, 부모 생성자가 실행될까? 등급은 왜 VIP로 남을까?
실행됩니다. 첫 줄에 생략된 super()가 자동으로 들어가 D1_Customer()가 먼저 돌며 SILVER·0.01을 넣고, 그 뒤에 자식 생성자 본문이 VIP·0.05로 덮어쓰기 때문에 VIP가 남습니다. 만약 D1_Customer에 기본 생성자가 없었다면 이 자동 super()가 갈 곳이 없어 컴파일 에러가 났을 것입니다.
컴파일 에러가 납니다. 오른쪽이 int + double = double이라 int 변수에 그대로 넣을 수 없기 때문입니다(손실 가능한 변환). += 같은 복합 대입은 bonusPoint = (int)(bonusPoint + …)처럼 형변환이 숨어 있어 통과하지만, 그 대가로 소수점이 경고 없이 버려집니다.
제목은 "2026년 8월"인데 9월 달력이 나옵니다. Calendar의 월은 0부터(0 = 1월)라 8을 넣으면 9월이 되기 때문입니다. 2026년 9월 1일은 화요일이라 앞 공백이 2칸, 마지막 날이 30일로 찍힙니다 — 에러 없이 틀린 결과가 나오는 함정이라, 새 코드에서는 월을 1~12로 쓰는 LocalDate를 쓰는 게 안전합니다.
💼 실무·코딩테스트에서는실무에서 날짜는 java.time(LocalDate·LocalDateTime)만 쓰고 Calendar·Date는 옛 코드에서만 만납니다. 등급별 할인·적립 같은 정책은 오늘처럼 상속으로 나누기도 하지만, 금액 계산을 double로 하면 오차가 생기므로 실제 결제 코드는 BigDecimal을 씁니다(오늘의 "+=가 소수점을 조용히 버림"도 같은 종류의 위험). 면접에서는 "protected의 접근 범위"와 "this()와 super()의 차이"를, 코딩테스트에서는 윤년·요일 계산이 날짜 문제의 기본기로 나옵니다.
TIPD1_VIPCustomer의 두 생성자를 비교해 보세요. 파라미터 없는 기본 생성자는 customerID·customerName을 안 채웁니다(등급·적립률만 세팅) — 4일차 D4_Constructor가 기본 생성자에서 this(24, "검정색")으로 위임했던 것과 달리, 여기서는 두 생성자가 위임 없이 같은 세팅 코드를 각자 들고 있는 경우라, 나중에 등급 정책이 바뀌면 두 생성자를 둘 다 고쳐야 하는 함정이 있습니다. this(...)로 위임하는 6~8일차 패턴과 비교하며 "어느 쪽이 유지보수하기 편한가"를 생각해 보면 좋은 복습이 됩니다.
한 줄 요약8~9일차의 상속·오버라이딩을 동물 계층(Animal → Human/Tiger/Eagle)으로 완성해 다형성이 실제로 발생하는 3가지 조건을 정리했고, 상속의 다음 단계인 추상 클래스를 컴퓨터 계층(Computer → NoteBook → MyNoteBook)으로 배웠다 — 공통 기능은 완성해서 물려주고, 자식마다 다른 기능만 추상 메서드로 비워 두는 설계다. 마지막엔 실습과제 9번의 홀수 마방진을 크기 n을 받아 어떤 홀수 크기든 만드는 클래스로 일반화해(실행은 7×7) 다시 구현했다.
쉽게 말하면추상 클래스는 미완성 설계도입니다 — "전원 켜기·끄기"처럼 모든 컴퓨터가 똑같이 하는 일은 미리 완성해 주고, "화면 어떻게 보여줄지"처럼 기종마다 다른 일은 빈칸으로 남겨 자식이 채우게 합니다. 그래서 new Computer()는 할 수 없습니다 — 미완성 설계도로는 아무것도 만들 수 없으니까요. 빈칸(추상 메서드)을 전부 채운 자식 클래스만 실제로 new할 수 있습니다.
① D1_Animal 계층 — 다형성이 발생하는 3가지 조건
주석에 명시된 순서 그대로입니다. ① 부모 타입으로 자식을 생성한다(D1_Animal human = new D1_Human();). ② 부모 타입으로 자식을 참조한다(D1_Animal animalTT = tiger;, 이미 만든 자식 객체를 부모 변수에 대입). ③ 메서드 파라미터를 부모 타입으로 선언해서(moveAnimal(D1_Animal animal)) 사람이든 호랑이든 독수리든 같은 코드로 처리합니다. 세 마리 모두 move()를 오버라이딩했기 때문에, 같은 animal.move() 호출이 실제 객체에 따라 "걷습니다"·"네발로 뜁니다"·"하늘을 납니다"로 각각 다르게 실행됩니다. 다만 수업 주석의 ①과 ②는 사실 같은 것(부모 타입 변수가 자식 객체를 가리킨다)을 new와 동시에 하느냐, 나중에 대입하느냐의 차이일 뿐입니다. 교재에서 흔히 쓰는 표준 정리는 다형성의 조건 = ⓐ 상속 관계 + ⓑ 자식의 오버라이딩 + ⓒ 부모 타입 변수로 자식 객체를 참조이고, 수업의 ①②는 ⓒ, ③은 ⓒ를 파라미터에 활용한 것입니다. (링크의 VMI는 Virtual Method Invocation — 실행할 때 실제 객체의 메서드를 찾아 부르는 방식.) → 🧱 19. 오버라이딩과 VMI
② 자식 전용 메서드는 다운캐스팅해야 보인다
D1_Animal human = new D1_Human(); 상태에서 human.eat()은 호출할 수 없습니다 — eat()은 D1_Human에만 있고 부모 D1_Animal의 설계도에는 없기 때문입니다. (D1_Human) human으로 다운캐스팅한 뒤에야 eat()이 보입니다. 원본 파일 아래쪽의 moveAnimal(Object obj) 오버로딩(위 코드 블록 끝)에서는 instanceof로 실제 타입을 하나씩 확인해 가며(D1_Human이면 사람으로, D1_Tiger면 호랑이로) 안전하게 형변환한 뒤 eat()을 부르는 패턴도 보여 줍니다. 단 이 메서드는 정의만 돼 있고 main에서 호출하지 않습니다.
③ D2_Computer 계층 — 추상 클래스로 "공통은 완성, 다른 건 빈칸"
abstract class D2_Computer는 turnOn()·turnOff()는 완성된 메서드로 제공하고, display()·typing()은 public abstract로 내용 없이 선언만 해 둡니다. D2_DeskTop은 둘 다 바로 구현하지만, D2_NoteBook은 display()만 구현하고 typing()은 여전히 abstract로 남겨 자기도 추상 클래스가 됩니다("노트북마다 키보드가 다르다"는 이유) — 그래서 실제로 new할 수 있는 건 typing()까지 구현한 D2_MyNoteBook뿐입니다. 추상 클래스를 상속하면 남은 추상 메서드를 반드시 구현하거나, 자기도 추상 클래스로 남아야 합니다. → 🧱 21. 추상 클래스
④ D3_OddMagicSquare — 실습과제 9번 마방진을 임의 크기로 일반화
마방진은 수업 일차가 아니라 실습과제 9번으로 먼저 나온 문제입니다(🧪 9번 마방진). 이번엔 n(홀수)을 생성자로 받아 어떤 홀수 크기든 만들 수 있게 일반화했습니다. 규칙은 "① 현재 칸 채우기 → ② 왼쪽 위 대각선으로 이동 → ③ 이미 채워져 있으면 대신 바로 아래로 이동 → ④ 격자를 벗어나면 반대편 끝으로 순환"입니다. line()·upline()·diagonal()·diagonal2()로 가로·세로·대각선 합이 전부 같은지 직접 검증하는 코드까지 포함했습니다. → 🧪 9번 마방진
// ※ 파일 다섯 개를 이어 붙인 블록입니다. public class 하나당 파일 하나이므로
// 복사할 때는 클래스별로 나눠 저장하세요(같은 package 선언을 각 파일 맨 위에).
package hk.edu20260814.day10;
public class D1_Animal {
public void move() {
System.out.println("동물이 움직입니다.");
}
}
public class D1_Human extends D1_Animal {
@Override
public void move() {
System.out.println("사람이 걷습니다.");
}
public void eat() {
System.out.println("밥먹음");
}
}
public class D1_Tiger extends D1_Animal {
@Override
public void move() {
System.out.println("호랑이는 네발로 뜁니다.");
}
public void eat() {
System.out.println("고기를 먹어요");
}
}
public class D1_Eagle extends D1_Animal {
@Override
public void move() {
System.out.println("독수리는 하늘을 납니다.");
}
public void eat() {
System.out.println("새를 먹어요");
}
}
public class D1_AnimalMain {
public static void main(String[] args) {
// 부모의 타입으로 자식을 생성
D1_Animal human = new D1_Human();
human.move(); // 자식에서 구현한 메서드가 실행(오버라이딩)
// human.eat(); //설계도에 공개된 메서드만 사용가능
D1_Human humanEat = (D1_Human) human;
humanEat.eat();
humanEat.move();
System.out.println("=====================================");
// 다형성 발생원리 3가지
// 1.부모의 타입으로 자식을 생성한다.
D1_Animal animalH = new D1_Human();
D1_Animal animalT = new D1_Tiger();
D1_Animal animalE = new D1_Eagle();
// 2.부모의 타입으로 자식을 참조한다.
D1_Tiger tiger = new D1_Tiger();
D1_Animal animalTT = tiger;
moveAnimal(animalH);
moveAnimal(animalT);
moveAnimal(animalE);
}
// 3. 파라미터에 부모 타입을 선언하면 서로 다른 자식들을 하나로 받을 수 있다
public static void moveAnimal(D1_Animal animal) {
animal.move();
}
// 다형성을 활용하지 않았을 경우 (정의만 하고 main에서는 호출하지 않음)
public static void moveAnimal(D1_Human human) {
human.move();
}
public static void moveAnimal(D1_Tiger tiger) {
tiger.move();
}
public static void moveAnimal(D1_Eagle eagle) {
eagle.move();
}
// Object 클래스를 이용하는 방법(Object는 모든 클래스에 부모)
public static void moveAnimal(Object obj) {
if (obj instanceof D1_Human) {
D1_Human human = (D1_Human) obj;
human.eat();
} else if (obj instanceof D1_Tiger) {
D1_Tiger tiger = (D1_Tiger) obj;
tiger.eat();
}
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
사람이 걷습니다.
밥먹음
사람이 걷습니다.
=====================================
사람이 걷습니다.
호랑이는 네발로 뜁니다.
독수리는 하늘을 납니다.
D1_Animal human = new D1_Human();이지만 human.move()는 변수 타입(D1_Animal)이 아니라 실제 객체(D1_Human)의 버전이 실행돼 "사람이 걷습니다"가 나옵니다. 아래 세 줄 moveAnimal(animalH/T/E)도 파라미터 타입은 전부 같은 D1_Animal인데 실제로 넘긴 객체에 따라 다른 문장이 출력됩니다 — 이게 다형성입니다.
// ※ 파일 다섯 개를 이어 붙인 블록입니다 — 복사할 때는 클래스별로 나눠 저장하세요.
package hk.edu20260814.day10;
public abstract class D2_Computer {
// 전원 켜고, 끄는 기능들은 노트북이나 데스크탑 모두 공통 기능이라
// 명확하게 정의할 수 있다.
public void turnOn() {
System.out.println("전원을 켭니다");
}
public void turnOff() {
System.out.println("전원을 끕니다");
}
// display, typing 기능: 컴퓨터 유형별로 다르기 때문에 여기서 구현을 못함
public abstract void display();
public abstract void typing();
}
//추상클래스를 상속받으면 반드시 추상메서드를 구현해야 된다.
public class D2_DeskTop extends D2_Computer {
@Override
public void display() {
System.out.println("데스크탑 화면 출력");
}
@Override
public void typing() {
System.out.println("데스크탑 키보드 입력");
}
}
public abstract class D2_NoteBook extends D2_Computer {
@Override
public void display() {
System.out.println("노트북 화면 출력");
}
// notebook마다 키보드 유형이 다르기때문에 여기서 명확하게 구현할 수 없다.
@Override
public abstract void typing();
}
public class D2_MyNoteBook extends D2_NoteBook {
// 하위 클래스에서 모두 구현을 하게 되면 상위 클래스들의 기능을 모두 사용할 수 있다.
@Override
public void typing() {
System.out.println("MyNoteBook typing기능입니다.");
}
}
public class D2_ComputerMain {
public static void main(String[] args) {
// D2_Computer com = new D2_Computer(); //불가능
D2_Computer desktop = new D2_DeskTop();
desktop.turnOn();
desktop.display();
desktop.typing();
desktop.turnOff();
// D2_Computer noteBook = new D2_NoteBook(); //안됨(NoteBook도 추상클래스)
D2_Computer MynoteBook = new D2_MyNoteBook();
MynoteBook.turnOn();
MynoteBook.display();
MynoteBook.typing();
MynoteBook.turnOff();
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
전원을 켭니다
데스크탑 화면 출력
데스크탑 키보드 입력
전원을 끕니다
전원을 켭니다
노트북 화면 출력
MyNoteBook typing기능입니다.
전원을 끕니다
turnOn()·turnOff()는 부모 D2_Computer에서 물려받은 그대로 실행되고, display()는 desktop이면 "데스크탑", MynoteBook이면 D2_NoteBook에서 물려받은 "노트북 화면 출력"이 나옵니다. typing()만 기종마다 완전히 다른 자신의 구현이 실행되는 걸 볼 수 있습니다.
JAVAD3_OddMagicSquare.java (수업 실습 원본 — 7×7로 실행)
package hk.edu20260814.day10;
public class D3_OddMagicSquare {
public int[][] magic;
public D3_OddMagicSquare() {
this(3);
}
public D3_OddMagicSquare(int n) {
this.magic = new int[n][n];
}
public void make() {
int n = magic.length;
int x = 0;
int y = n / 2;
magic[x][y] = 1; // 3x3일경우 (0,1) 위치에 1을 넣고 시작
for (int i = 2; i <= n * n; i++) {
int tempX = x, tempY = y; // 값 변경전에 원본값을 저장
if (x - 1 < 0) x = n - 1; else x--;
if (y - 1 < 0) y = n - 1; else y--;
if (magic[x][y] != 0) { // 이미 수가 채워져 있다면 원래 위치에서 x+1
x = tempX + 1;
y = tempY;
}
magic[x][y] = i;
}
}
public int line(int i) { // 가로의 합
int sum = 0;
for (int j = 0; j < magic.length; j++) sum += magic[i][j];
return sum;
}
public int upline(int i) { // 세로의 합
int sum = 0;
for (int j = 0; j < magic.length; j++) sum += magic[j][i];
return sum;
}
public int diagonal() { // 대각선(왼->오)
int sum = 0;
for (int i = 0; i < magic.length; i++) sum += magic[i][i];
return sum;
}
public int diagonal2() { // 대각선(오->왼)
int sum = 0;
for (int i = 0; i < magic.length; i++) sum += magic[i][magic.length - 1 - i];
return sum;
}
public boolean isCheck() { // 가로·세로·대각선 합이 전부 같은지 확인
int n = magic.length;
int[] ma = new int[n * 2 + 2];
for (int i = 0; i < n; i++) {
ma[i] = line(i);
ma[i + n] = upline(i);
}
ma[n * 2] = diagonal();
ma[n * 2 + 1] = diagonal2();
for (int i = 1; i < ma.length; i++) {
if (ma[i] != ma[0]) return false;
}
return true;
}
}
7×7 마방진의 모든 가로·세로·대각선 합이 175로 똑같이 나와서(1부터 49까지의 합 1225 ÷ 7 = 175) isCheck()가 true를 반환합니다. 3×3 마방진과 원리는 완전히 같고 n만 커진 것입니다.
10일차에 배운 것 — 핵심 정리
다형성이 발생하는 3가지 형태(수업 정리): 부모 타입으로 자식 생성 / 부모 타입으로 자식 참조 / 파라미터를 부모 타입으로 선언. 표준 정리로는 상속 + 오버라이딩 + 부모 타입 참조.
부모 타입 변수에는 자식 전용 메서드가 안 보인다 — 다운캐스팅해야 호출 가능.
추상 클래스는 abstract 키워드로 선언하며 new로 직접 객체를 만들 수 없다.
추상 클래스는 완성된 메서드(공통 기능)와 추상 메서드(자식마다 다른 기능)를 함께 가질 수 있다.
추상 클래스를 상속한 자식이 추상 메서드를 전부 구현하지 않으면 그 자식도 추상 클래스가 된다.
홀수 마방진: 시작은 맨 윗줄 가운데, 이후 왼쪽 위 대각선으로 이동하되 이미 채워져 있으면 바로 아래로, 격자를 벗어나면 반대편 끝으로 순환한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D1_Animal human = new D1_Human();에서 human.move()는 "사람이 걷습니다"가 되는데, human.eat()은 왜 컴파일조차 안 될까?
"어떤 메서드를 부를 수 있나"는 컴파일러가 변수 타입으로, "어느 버전을 실행하나"는 실행할 때 실제 객체로 정하기 때문입니다. D1_Animal 설계도에는 move()만 있어 eat()은 호출 자체가 막히고, move()는 실제 객체인 D1_Human의 재정의 버전이 실행됩니다. eat()을 쓰려면 (D1_Human) human으로 다운캐스팅해야 합니다.
moveAnimal(animalH)와 moveAnimal(humanEat)는 둘 다 같은 사람 객체인데, 같은 moveAnimal 메서드가 불릴까?
아닙니다. 오버로딩은 컴파일할 때 인자의 변수 타입으로 고릅니다. animalH는 D1_Animal 타입이라 moveAnimal(D1_Animal)이, humanEat는 D1_Human 타입이라 더 구체적인 moveAnimal(D1_Human)이 선택됩니다. 출력은 둘 다 "사람이 걷습니다."로 같지만 거쳐 가는 메서드가 다릅니다 — 오버로딩은 변수 타입, 오버라이딩은 실제 객체가 정한다는 차이입니다.
D2_Computer noteBook = new D2_NoteBook();은 왜 안 되고, D2_Computer 타입 변수 자체는 왜 만들 수 있을까?
D2_NoteBook은 typing()을 아직 abstract로 남겨 둔 추상 클래스라 new로 객체를 만들 수 없습니다(컴파일 에러). 하지만 변수의 타입으로는 추상 클래스도 쓸 수 있어서, D2_Computer MynoteBook = new D2_MyNoteBook();처럼 빈칸을 다 채운 자식 객체를 담는 데 씁니다 — 추상 클래스는 "객체를 못 만들 뿐, 타입으로는 쓰는" 존재입니다.
💼 실무·코딩테스트에서는"공통 흐름은 부모가 완성하고, 달라지는 단계만 자식이 채운다"는 추상 클래스 설계는 실무에서 템플릿 메서드 패턴이라고 부르며, 웹 수업에서 만날 HttpServlet도 doGet()·doPost()를 자식이 재정의해 쓰는 추상 클래스입니다. 면접에서는 "오버로딩과 오버라이딩의 차이", "추상 클래스는 왜 new가 안 되나"를 자주 묻습니다. 마방진의 "벗어나면 반대편으로" 이동은 코딩테스트 격자 문제에서 (x - 1 + n) % n 한 줄로 쓰는 경우가 많습니다.
TIPD1_AnimalMain에 moveAnimal(D1_Human)·moveAnimal(D1_Tiger)·moveAnimal(D1_Eagle)처럼 같은 이름, 다른 파라미터 타입의 오버로딩 버전들도 같이 정의되어 있습니다. 하지만 실제 main에서 호출한 건 전부 moveAnimal(D1_Animal animal) 하나뿐이었죠 — 다형성을 쓰면 오버로딩으로 자식 타입별 메서드를 따로 만들 필요가 없어진다는 걸 대비시켜 보여주는 코드입니다. 오버로딩(6일차)과 다형성(오늘)을 같은 예제 안에서 비교해 두면 두 개념이 헷갈리지 않습니다.
한 줄 요약10일차 추상 클래스의 다음 단계인 인터페이스를 배운 날 — 필드는 자동으로 public static final 상수, 메서드는 자동으로 public abstract가 된다는 기본 규칙에 이어 JDK 8부터 생긴 default·static·private 메서드까지 전부 한 인터페이스에 넣어 확인했고, 이름 없이 즉석에서 구현하는 익명 클래스, 그리고 추상 클래스가 인터페이스를 구현하면서 일부만 완성해 물려주는 조합 설계(마방진 3단 구조)까지 이어졌다.
쉽게 말하면인터페이스는 "이 기능들은 반드시 구현해야 한다"는 계약서입니다. 추상 클래스가 "일부는 완성해서 주고 일부는 비워 두는" 것이라면, 인터페이스는 원래는 통째로 빈칸(계약)뿐이었는데, JDK 8부터 default 메서드로 "기본값은 이렇게 해 두겠다"는 구현까지 넣을 수 있게 됐습니다. 익명 클래스는 일회용 컵처럼 이름도 없이 그 자리에서 한 번 쓰고 마는 구현체입니다.
① D1_InterfaceTest — 인터페이스 메서드 5종류
test1()·test3()처럼 아무 키워드 없이 써도, test2()처럼 abstract를 붙여도 전부 자동으로 추상 메서드입니다. test5()는 default로 몸체가 있는 메서드(자식이 안 고쳐도 바로 쓸 수 있고, 원하면 재정의도 가능), test6()은 private으로 인터페이스 내부에서만 쓰는 헬퍼, test7()은 static으로 객체 없이 인터페이스명.메서드()로 바로 호출합니다. 필드 b·A는 키워드를 안 붙여도 자동으로 public static final 상수가 됩니다. → 🧱 22. 인터페이스와 추상 클래스의 차이
② D1_InterfaceChild vs 익명 클래스 — 두 가지 구현 방법
D1_InterfaceChild implements D1_InterfaceTest는 이름 있는 클래스로 추상 메서드 3개(test1~3)를 구현하고, test5()(default)도 재정의했습니다. D1_InterfaceMain에서는 D1_InterfaceTest it2 = new D1_InterfaceTest() { ... };처럼 이름 없이 그 자리에서 즉시 구현하고 바로 쓰는 익명 클래스도 만들었습니다 — 인터페이스는 그 자체로 new할 수 없지만, 익명 클래스로 감싸면 그 순간 구현체가 생겨 가능해집니다. 재사용할 필요 없이 딱 한 번만 쓸 구현이라면 익명 클래스가 편합니다. → 🧱 23. 중첩 클래스(익명 클래스)
③ D2_Calc / D2_Calculator / D2_CalculatorChild — 인터페이스 + 추상 클래스 조합
D2_Calc(인터페이스)가 사칙연산 4개를 계약으로 걸고, abstract class D2_Calculator implements D2_Calc가 덧셈·뺄셈은 완성해서 구현하고 곱셈·나눗셈은 여전히 abstract로 남겨 둡니다(10일차 D2_NoteBook과 같은 패턴). D2_CalculatorChild가 나머지 둘을 구현해야 비로소 new할 수 있는 완성 클래스가 됩니다. D2_Calc calc = new D2_CalculatorChild();(인터페이스 타입 참조)와 D2_Calculator calc2 = ...(추상 클래스 타입 참조)를 나란히 만들어, 어느 타입으로 참조하느냐에 따라 보이는 메서드가 다르다는 걸 다시 확인했습니다(9~10일차 다운캐스팅 감각의 연장). → 🧱 21. 추상 클래스 · 🧱 22. 인터페이스
④ day11_2 마방진 — 인터페이스 → 추상 클래스 → 구현 클래스 3단 구조
Interface_Magic(계약: make()·magicPrint()) → MagicSquare(추상 클래스, implements Interface_Magic — sumCol·sumRow·sumDia·isCheck() 같은 공통 검증 로직은 완성해서 제공하고 make()는 빈 몸체로 재선언) → OddMagicSquare(실제 구현, make()·magicPrint() 완성)로 이어집니다. 10일차의 D3_OddMagicSquare가 계산 로직 하나에 다 들어 있던 것과 달리, 오늘 버전은 "약속(인터페이스)" · "공통 기능(추상 클래스)" · "실제 구현(자식 클래스)"을 계층으로 분리한 설계입니다. → 🧪 9번 마방진
TIP이 구조는 12일차에 이어서 다듬어졌습니다 — sumRow·sumCol 이름이 실제로 하는 일과 반대로 붙어 있던 것을 바로잡고, 빈 몸체였던 make()를 진짜 abstract로 바꾸고, magicPrint()를 MagicSquare로 끌어올려 진짜 부모·자식이 공유하는 형태로 완성했습니다. 그 김에 짝수 마방진(EvenMagicSquare)도 같은 구조로 추가됐어요. → 📅 12일차 카드에서 고친 내용 보기
package hk.edu20260818.day11;
public interface D1_InterfaceTest {
// 맴버필드 선언 -> 자동으로 상수가 됨
public int b = 50;
public static final int A = 30;
// 추상메서드: 그냥 작성해도 자동 추상메서드가 된다.
public void test1();
public abstract int test2();
public int test3();
// default 메서드: 구현이 있는 메서드
public default void test5() {
System.out.println("인터페이스에서 기능을 구현할 수 있는 메서드");
}
// private 메서드: 인터페이스 내부에서만 접근
private void test6() {
System.out.println("인터페이스 내부에서만 사용가능한 메서드.");
}
// static 메서드: 독립적인 기능을 제공할 때
static void test7() {
System.out.println("인터페이스에서 독립적인 기능을 제공하는 메서드.");
}
}
public class D1_InterfaceChild implements D1_InterfaceTest {
@Override
public void test1() {
System.out.println("메서드구현해야 함");
}
@Override
public int test2() { return 0; }
@Override
public int test3() { return 0; }
public void test4() {
System.out.println("자식에서 따로 구현한 메서드");
}
// default 메서드를 재정의해서 사용
@Override
public void test5() {
System.out.println("인터페이스의 디폴트 메서드를 오버라이딩 할 수 있다.");
}
}
public class D1_InterfaceMain {
public static void main(String[] args) {
D1_InterfaceChild it = new D1_InterfaceChild();
it.test1();
it.test2();
it.test5();
D1_InterfaceTest.test7(); // static 메서드
// 익명 클래스 -> 이름이 없는 클래스 정의 -> 재활용X
// interface는 자체적으로 객체 생성X
D1_InterfaceTest it2 = new D1_InterfaceTest() {
@Override public void test1() { System.out.println("인라인 익명클래스에서 구현한 test1"); }
@Override public int test2() { return 0; }
@Override public int test3() { return 0; }
@Override public void test5() { System.out.println("인라인 익명클래스에서 구현한 test5"); }
};
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
메서드구현해야 함
인터페이스의 디폴트 메서드를 오버라이딩 할 수 있다.
인터페이스에서 독립적인 기능을 제공하는 메서드.
it.test2()는 0을 반환만 할 뿐 출력을 안 해서 화면엔 안 보이고, it.test5()는 D1_InterfaceChild가 재정의한 버전("...오버라이딩 할 수 있다")이 실행됩니다. D1_InterfaceTest.test7()은 인터페이스명으로 바로 호출한 static 메서드입니다. 익명 클래스 it2는 만들기만 하고 메서드를 호출하지 않아 화면엔 출력이 없습니다.
package hk.edu20260818.day11;
public interface D2_Calc {
double PI = 3.14;
int ERROR = -999999;
int add(int num1, int num2);
int substract(int num1, int num2);
int times(int num1, int num2);
int divide(int num1, int num2);
}
public abstract class D2_Calculator implements D2_Calc {
@Override
public int add(int num1, int num2) { return num1 + num2; }
@Override
public int substract(int num1, int num2) { return num1 - num2; }
@Override
public abstract int times(int num1, int num2);
@Override
public abstract int divide(int num1, int num2);
public void showInfoParent() {
System.out.println("부모 클래스에서만 정의한 메서드");
}
}
public class D2_CalculatorChild extends D2_Calculator {
@Override
public int divide(int num1, int num2) {
if (num2 != 0) return num1 / num2;
return ERROR;
}
@Override
public int times(int num1, int num2) { return num1 * num2; }
public void showInfoChild() {
System.out.println("자식클래스에서만 정의한 메서드");
}
}
public class D2_CalculatorMain {
public static void main(String[] args) {
D2_Calc calc = new D2_CalculatorChild(); // 인터페이스 타입으로 자식생성
D2_Calculator calc2 = new D2_CalculatorChild(); // 추상클래스 타입으로 자식 생성
System.out.println("calc:" + calc.add(5, 5));
// showInfo~() 호출못함 (D2_Calc엔 없음)
calc2.showInfoParent(); // 부모클래스 타입이므로 부모에 공개된 메서드까지 호출 가능
D2_CalculatorChild calc3 = (D2_CalculatorChild) calc2; // 형변환
calc3.showInfoChild(); // 자식클래스 메서드는 자식타입으로 형변환해야 사용가능
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
calc:10
부모 클래스에서만 정의한 메서드
자식클래스에서만 정의한 메서드
calc(인터페이스 타입)는 D2_Calc에 선언된 메서드만 호출할 수 있어 add()는 되지만 showInfoParent()는 안 보입니다. calc2(추상 클래스 타입)는 D2_Calculator에 공개된 showInfoParent()까지 보이고, calc3(자식 타입으로 다운캐스팅)에서야 비로소 showInfoChild()가 보입니다 — 타입이 "몇 층까지 보이는지"를 결정한다는 게 이 예제의 핵심입니다.
JAVAday11_2/Interface_Magic.java + MagicSquare.java + OddMagicSquare.java (수업 실습 원본, isCheck 등 합계 메서드는 10일차와 동일해 생략)
package hk.edu20260818.day11.day11_2;
public interface Interface_Magic {
void make();
void magicPrint();
}
public abstract class MagicSquare implements Interface_Magic {
protected int[][] magic;
public MagicSquare() {
}
public MagicSquare(int n) {
this.magic = new int[n][n];
}
@Override
public void make() { } // 자식이 다시 구현(사실상 추상처럼 사용)
@Override
public void magicPrint() { } // 자식이 다시 구현
// sumCol · sumRow · sumDia · sumReverseDia · isCheck()는
// 10일차 D3_OddMagicSquare의 line·upline·diagonal·diagonal2·isCheck와 동일한 로직
}
public class OddMagicSquare extends MagicSquare {
public OddMagicSquare() {
super(3);
}
public OddMagicSquare(int n) {
super(n); // 부모 MagicSquare(int n)이 배열을 만든다
}
public void make() {
// 10일차와 동일한 홀수 마방진 채우기 알고리즘
}
public void magicPrint() {
for (int i = 0; i < magic.length; i++) {
for (int j = 0; j < magic.length; j++) {
System.out.print(magic[i][j] + "\t");
}
System.out.print(sumCol(i));
System.out.println();
}
for (int i = 0; i < magic.length; i++) {
System.out.print(sumRow(i) + "\t");
}
System.out.println();
System.out.println("마방진 증명여부:" + isCheck());
}
}
public class D3_MagicSquareMain {
public static void main(String[] args) {
OddMagicSquare odd = new OddMagicSquare(11);
odd.make();
odd.magicPrint();
}
}
실행 결과11×11로 실행 (앞 4줄만 발췌, 실제로는 11줄) 먼저 예측 → 펼쳐서 확인
11×11 마방진의 모든 가로·세로·대각선 합이 671(1부터 121까지의 합 7381 ÷ 11)로 똑같이 나와 isCheck()가 true입니다. 계약(Interface_Magic) · 공통 기능(MagicSquare) · 실제 구현(OddMagicSquare)으로 층을 나눠도 결과는 10일차 버전과 동일하다는 게 확인됩니다.
11일차에 배운 것 — 핵심 정리
인터페이스 필드는 자동 public static final, 메서드는 키워드 없이 써도 자동 public abstract.
인터페이스는 implements로 구현하며 여러 개를 동시에 구현할 수 있다(클래스 상속은 하나만).
익명 클래스는 이름 없이 그 자리에서 즉시 구현하는 일회성 클래스 — 인터페이스·추상 클래스를 재사용 없이 한 번만 쓸 때 편하다.
추상 클래스가 인터페이스를 구현하면서 일부 메서드만 완성하고 나머지는 abstract로 남겨 자식에게 미룰 수 있다.
참조 타입(인터페이스 / 추상 클래스 / 구현 클래스)에 따라 그 타입에 공개된 메서드까지만 보인다 — 더 아래층 메서드는 다운캐스팅해야 호출 가능.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
인터페이스는 new로 객체를 못 만든다면서, D1_InterfaceMain의 new D1_InterfaceTest() { ... }는 왜 컴파일될까?
그 줄이 만드는 것은 인터페이스 자체가 아니라 그 자리에서 정의한 "이름 없는 구현 클래스"의 객체이기 때문입니다. 중괄호 안에서 추상 메서드 test1~3을 모두 구현했으니 완성된 클래스가 되고, 컴파일러는 이를 D1_InterfaceMain$1 같은 이름의 클래스로 따로 만듭니다. 추상 메서드를 하나라도 빼먹으면 컴파일 에러입니다.
day11_2의 OddMagicSquare에서 make()를 깜빡 구현하지 않으면 어떻게 될까?
컴파일도 되고 실행도 되지만 결과가 틀립니다. 부모 MagicSquare의 make() { }는 abstract가 아니라 텅 빈 구현이라, 자식이 안 고쳐도 그 빈 메서드가 불려 배열이 전부 0으로 남습니다. 그러면 모든 합이 0으로 같아 isCheck()가 true를 돌려주는, 오류 없이 숨는 버그가 됩니다 — 12일차에 public abstract void make();로 바꾼 이유입니다.
-999999입니다. num2가 0이라 ERROR를 돌려주는데, 이 값은 인터페이스 D2_Calc의 필드라 자동으로 public static final 상수이고, 인터페이스를 구현한 클래스(손자인 D2_CalculatorChild 포함)는 이름만으로 바로 쓸 수 있습니다. 다만 -999999가 진짜 계산 결과일 수도 있어서, 실무라면 이런 "특별한 값" 대신 예외를 던지는 편이 안전합니다.
💼 실무·코딩테스트에서는실무 자바는 인터페이스 타입으로 받고 구현체는 갈아 끼우는 코드가 기본입니다 — List<String> list = new ArrayList<>();, JDBC의 Connection, 스프링의 MemberService 인터페이스 + 구현 클래스가 모두 오늘의 D2_Calc calc = new D2_CalculatorChild();와 같은 모양입니다. 익명 클래스는 요즘 대부분 람다로 줄여 쓰는데, 코딩테스트에서 정렬 기준을 넘기는 Comparator가 대표적입니다(정수 내림차순으로 배치하기의 Collections.reverseOrder()). 면접에서는 "추상 클래스와 인터페이스의 차이"와 "default 메서드가 생긴 이유"(이미 구현한 클래스들을 깨지 않고 인터페이스에 기능 추가)를 자주 묻습니다.
TIP 오늘 나온 세 가지 "빈 몸체" 문법을 비교해서 기억하세요. ① 인터페이스의 void make();(몸체 자체가 없음 — 자동 abstract), ② 추상 클래스의 public abstract void make();(명시적으로 abstract, 몸체 없음), ③ MagicSquare의 public void make() { }(몸체는 있지만 텅 비어 있음 — 이건 abstract가 아니라서 자식이 안 고쳐도 컴파일은 되지만, 실제로 호출하면 아무 일도 안 일어나는 함정). 시험에서 "이 메서드는 자식이 반드시 재정의해야 하는가?"를 물으면, ①·②는 그렇다(YES), ③은 아니다(NO, 구현을 잊으면 조용히 아무것도 안 하는 버그가 된다)는 걸 구분해야 합니다.
static 내부 클래스인스턴스 내부 클래스지역 내부 클래스익명 클래스짝수 마방진제네릭와일드카드
한 줄 요약클래스 안에 클래스를 정의하는 중첩 클래스 4종(정적 내부·인스턴스 내부·지역 내부·익명)을 한 파일에서 나란히 비교했고, 11일차 마방진 코드로 돌아가 이름이 뒤바뀌어 있던 sumRow/sumCol 버그와 비어 있던 make()/magicPrint()를 고쳐 진짜 공통 로직으로 완성한 뒤 짝수(4의 배수) 마방진까지 같은 구조로 얹었으며, 마지막으로 제네릭 클래스 <T>와 와일드카드 <? extends T>로 타입을 나중에 정하는 문법을 정리했다.
쉽게 말하면중첩 클래스 4종은 "어디서 얼마나 오래 쓸 물건이냐"로 구분하면 쉽습니다 — 독립적으로 자주 쓸 거면 정적 내부, 바깥 객체에 딸린 거면 인스턴스 내부, 메서드 안에서 잠깐만 쓸 거면 지역, 딱 한 번 쓰고 버릴 거면 익명입니다. 오늘 마방진 리팩터링은 어제 만든 서랍장의 이름표가 잘못 붙어 있던 걸 바로잡고, 비어 있던 서랍을 채운 것과 같습니다. 제네릭은 상자에 라벨을 붙이는 것 — 어떤 타입을 담을지 상자를 만들 때가 아니라 사용할 때 정합니다.
① D1_NestedClassTest — 중첩 클래스 4종을 한 번에 비교
정적 내부 클래스(static class StaticInnerClass)는 바깥 객체 없이 new StaticInnerClass(...)로 바로 만들 수 있고, 바깥 클래스의 static 필드만 접근할 수 있습니다(aa + bb). 인스턴스 내부 클래스(class InnerClass)는 바깥 객체를 먼저 만들어야 하고(new D1_NestedClassTest().new InnerClass(...)), 그 대신 바깥의 인스턴스 필드까지 접근할 수 있습니다(a + b). 지역 내부 클래스는 nestedMethod() 안에서만 정의·사용되고, 메서드의 지역변수 c를 참조하는데 이 변수가 이후 값이 바뀌면 컴파일 에러가 납니다(사실상 final이어야 함) — 메서드가 끝나도 내부 클래스 객체가 그 값을 계속 들고 있어야 하기 때문입니다. 익명 클래스는 new MagicSquare() { ... }처럼 이름 없이 그 자리에서 즉시 구현하는 방식으로, 11일차에 배운 것을 다시 확인했습니다. → 🧱 23. 중첩 클래스(Nested Class)
② 마방진 리팩터링 — 이름이 뒤바뀐 버그와 빈 메서드를 고치다
11일차 MagicSquare를 다시 열어 보니 두 가지 문제가 있었습니다. 하나, sumCol(i)이 실제로는 가로(행) 합을 계산하고 있었고sumRow(j)가 세로(열) 합을 계산하고 있었습니다 — 이름과 동작이 정반대였던 버그입니다. 오늘 sumRow는 진짜 가로 합, sumCol은 진짜 세로 합을 계산하도록 이름을 맞바꿨습니다. 둘, make()와 magicPrint()가 몸체만 비어 있는 "가짜 구현"이었는데(추상이 아니라 아무 일도 안 하는 빈 메서드), make()는 public abstract void make();로 진짜 추상 메서드로 되돌리고, magicPrint()는 OddMagicSquare에만 있던 걸 MagicSquare로 끌어올려 자식들이 공유하게 했습니다. 그 결과 OddMagicSquare 생성자도 this.magic = new int[n][n]; 대신 super(n) 한 줄로 줄었습니다. → 🧱 21. 추상 클래스
③ EvenMagicSquare — 4의 배수 짝수 마방진, 두 단계로 채우기
makeA()가 1부터 n²까지 순서대로[i/n][i%n] 공식으로 격자를 채우고, makeB()가 특정 영역만 골라 반대 값(n²+1 - 원래값)으로 덮어씁니다. 영역 판정은 4등분한 좌표 범위로 나뉘는데, 가운데 세로 2/4 구간과 위아래 바깥 1/4 구간(i < n/4 또는 i ≥ n/4*3일 때 j가 가운데 2/4)이 한 그룹, 나머지 위아래 가운데 구간에서는 바깥쪽 좌우 1/4 구간이 반대 그룹입니다 — 대각선 대칭 위치의 두 수를 맞바꾸는 효과입니다. day11_2의 EvenMagicSquare는 MagicSquare를 상속해서 sumRow·sumCol·isCheck()를 그대로 재사용하지만, day12의 D2_EvenMagicSquare는 같은 알고리즘을 독립 클래스로 처음부터 다시 구현했습니다 — 상속하면 코드가 얼마나 줄어드는지 비교하기 좋은 짝입니다. → 🧪 9번 마방진
TIP마방진 시리즈는 14일차에 한 단계 더 나아갑니다 — 6마방진(4로 나누면 2가 남는 크기)을 추가하고, 완성된 OddMagicSquare·EvenMagicSquare·SixMagicSquare 셋을 싱글턴 팩토리로 한데 묶습니다. → 📅 14일차 카드 보기
④ D3_GBox<T> — 제네릭 클래스와 와일드카드
class D3_GBox<T>는 T 자리에 어떤 타입이 올지 객체를 만들 때 정하는 상자입니다 — D3_GBox<String>과 D3_GBox<Integer>가 같은 클래스 코드를 재사용합니다. List<? extends Number>는 와일드카드로, "Number거나 그 하위 타입 중 아무거나"를 받을 수 있게 합니다 — 그래서 List<Integer>를 대입할 수 있지만(numbers = num;), 정확히 어떤 하위 타입인지 컴파일러가 모르기 때문에 numbers.add(100)은 막힙니다(읽기는 되지만 쓰기는 안전하지 않음). printList1(List<? extends Number> list)처럼 파라미터를 와일드카드로 선언하면 List<Integer>든 List<Double>이든 타입 계층을 유지한 채로 하나의 메서드로 받을 수 있습니다. → 🧱 24. 제네릭(Generic)
JAVAD1_NestedClassTest.java (수업 실습 원본)
package hk.edu20260819.day12;
import hk.edu20260818.day11.day11_2.MagicSquare;
public class D1_NestedClassTest {
// 인스턴스 멤버필드
public int a = 5;
public int b = 10;
// 정적 멤버필드
public static int aa = 7;
public static int bb = 3;
// 정적 내부 클래스: 독립적으로 사용 가능
static class StaticInnerClass {
public String message;
public StaticInnerClass(String message) {
this.message = message;
}
public int getResult() {
int result = aa + bb; // static 필드만 접근 가능하다.
return result;
}
}
// 인스턴스 내부 클래스
class InnerClass {
public String message;
public InnerClass(String message) {
this.message = message;
}
public int getResult() {
int result = a + b;
return result;
}
}
public void nestedMethod() {
int c = 5;
class LocalInnerClass {
public int number;
public LocalInnerClass(int number) {
this.number = number;
}
public int getLic() {
int ss = c;
return ss;
}
}
// c=50; 값이 변경되는 코드가 있다면 오류발생 -> 변경여지가 없는 변수여야 한다.
LocalInnerClass licObj = new LocalInnerClass(200);
System.out.println("지역내부클래스: " + licObj.getLic());
}
public static void main(String[] args) {
// 정적내부 클래스: 일반 클래스처럼 객체 생성해서 사용한다.
StaticInnerClass sic = new StaticInnerClass("정적내부클래스");
System.out.println(sic.getResult());
// 인스턴스 내부 클래스: 외부클래스를 먼저 객체 생성하고, 내부클래스를 생성해야 사용할 수 있다.
InnerClass inner = new D1_NestedClassTest().new InnerClass("내부클래스");
System.out.println(inner.getResult());
// 익명클래스: 인터페이스/추상클래스를 그 자리에서 구현해서 객체생성한다.
// -> 클래스를 따로 정의하지 않기 때문에 이름이 없고, 외부에서 재사용 못함
MagicSquare magic = new MagicSquare() {
@Override
public void make() {
}
@Override
public void magicPrint() {
}
};
magic.make();
magic.magicPrint();
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
10
15
정적 내부 클래스의 getResult()는 aa + bb = 7 + 3 = 10, 인스턴스 내부 클래스는 a + b = 5 + 10 = 15를 출력합니다. nestedMethod()는 main에서 호출되지 않아 지역 내부 클래스 쪽 출력은 나오지 않고, 익명 클래스의 make()·magicPrint()도 몸체가 비어 있어 아무것도 찍지 않습니다.
JAVAMagicSquare.java (11일차 코드를 고친 버전 — sumRow/sumCol 이름 수정 · make() 진짜 추상화 · magicPrint() 공통화)
// ※ 파일 세 개를 이어 붙인 블록입니다. public class 하나당 파일 하나이므로
// 복사할 때는 클래스별로 나눠 저장하세요(같은 package 선언을 각 파일 맨 위에).
package hk.edu20260818.day11.day11_2;
import java.util.Arrays;
public abstract class MagicSquare implements Interface_Magic {
protected int[][] magic;
// 기본 생성자 — D1_NestedClassTest의 익명 클래스 new MagicSquare() { ... }가 이걸 부른다
// (파라미터 있는 생성자만 있으면 기본 생성자가 자동으로 안 생겨 컴파일 에러)
public MagicSquare() {
}
public MagicSquare(int n) {
this.magic = new int[n][n];
}
@Override
public abstract void make(); // 예전엔 빈 몸체 구현이었음 -> 진짜 추상 메서드로 수정
// 마방진 출력하기 — 예전엔 OddMagicSquare에만 있던 걸 부모로 끌어올려 자식들이 공유
@Override
public void magicPrint() {
for (int i = 0; i < magic.length; i++) {
for (int j = 0; j < magic.length; j++) {
System.out.print(magic[i][j] + "\t");
}
System.out.print(sumRow(i));
System.out.println();
}
for (int i = 0; i < magic.length; i++) {
System.out.print(sumCol(i) + "\t");
}
System.out.println();
System.out.println("마방진 증명여부:" + isCheck());
}
// 가로의 합 구하는 기능 (예전엔 sumCol이라는 이름으로 이 일을 하고 있었음)
public int sumRow(int i) {
int tot = 0;
for (int j = 0; j < magic.length; j++) tot += magic[i][j];
return tot;
}
// 세로의 합 구하는 기능 (예전엔 sumRow라는 이름으로 이 일을 하고 있었음)
public int sumCol(int j) {
int tot = 0;
for (int i = 0; i < magic.length; i++) tot += magic[i][j];
return tot;
}
public int sumDia() {
int tot = 0;
for (int i = 0; i < magic.length; i++) tot += magic[i][i];
return tot;
}
public int sumReverseDia() {
int tot = 0;
for (int i = 0; i < magic.length; i++) tot += magic[i][magic.length - 1 - i];
return tot;
}
public boolean isCheck() {
boolean isC = true;
int n = magic.length;
int[] ma = new int[n * 2 + 2];
for (int i = 0; i < n; i++) {
ma[i] = sumRow(i);
ma[i + n] = sumCol(i);
}
ma[n * 2] = sumDia();
ma[n * 2 + 1] = sumReverseDia();
System.out.println("ma결과:" + Arrays.toString(ma)); // 출력의 "ma결과:[…]" 줄
for (int i = 1; i < ma.length; i++) {
if (ma[0] != ma[i]) { isC = false; break; }
}
return isC;
}
}
// ===== 여기부터 OddMagicSquare.java (별도 파일) =====
//OddMagicSquare는 이제 magicPrint()가 없어도 부모 걸 그대로 물려받는다
public class OddMagicSquare extends MagicSquare {
public OddMagicSquare() {
super(3);
}
public OddMagicSquare(int n) {
super(n); // 예전엔 this.magic = new int[n][n]; 을 직접 중복 작성했음
}
public void make() {
// ... 11일차와 동일한 홀수 마방진 채우기 알고리즘
}
}
// ===== 여기부터 EvenMagicSquare.java (별도 파일) =====
// 짝수(4의 배수) 마방진 — 같은 부모를 상속해서 sumRow·sumCol·isCheck를 그대로 재사용
public class EvenMagicSquare extends MagicSquare {
public EvenMagicSquare() {
super(4);
}
public EvenMagicSquare(int n) {
super(n);
}
public void make() {
makeA();
makeB();
}
// 1~n*n까지 숫자를 순서대로 채우기
public void makeA() {
int n = magic.length;
for (int i = 0; i < n * n; i++) {
magic[i / n][i % n] = i + 1; // [i/열개수][i%열개수] 공식(7일차 배열 변환과 동일)
}
}
// 특정 영역만 반대 값(n*n+1 - 원래값)으로 덮어쓰기
public void makeB() {
int n = magic.length;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if ((i >= 0 && i < n / 4) || (i >= n / 4 * 3 && i < n)) {
if (j >= n / 4 && j < n / 4 * 3) {
magic[i][j] = (n * n) - (i * n + j);
}
} else {
if ((j >= 0 && j < n / 4) || (j >= n / 4 * 3 && j < n)) {
magic[i][j] = (n * n) - (i * n + j);
}
}
}
}
}
}
실행 결과D3_MagicSquareMain으로 홀수 3×3 + 짝수 4×4를 이어서 실행 먼저 예측 → 펼쳐서 확인
3×3 홀수 마방진은 합이 15, 4×4 짝수 마방진은 합이 34로 모두 참(true)입니다. 이제 OddMagicSquare와 EvenMagicSquare 둘 다 make()만 직접 구현하고 magicPrint()·sumRow·sumCol·isCheck()는 MagicSquare에서 그대로 물려받습니다 — 상속이 왜 "중복 제거"인지 보여주는 예시입니다.
JAVAD3_GBox.java (수업 실습 원본 — 제네릭 클래스와 와일드카드)
package hk.edu20260819.day12;
import java.util.ArrayList;
import java.util.List;
public class D3_GBox<T> {
private T item;
public void set(T item) {
this.item = item;
}
public T get() {
return item;
}
public static void main(String[] args) {
// String타입으로 정의
D3_GBox<String> strBox = new D3_GBox<>();
strBox.set("Hello~~~");
System.out.println(strBox.get());
// Integer타입으로 정의
D3_GBox<Integer> intBox = new D3_GBox<>();
intBox.set(1000);
System.out.println(intBox.get());
// 와일드카드: <? extends T> T와 T의 하위타입들: 읽기전용
List<? extends Number> numbers = new ArrayList<Integer>();
// numbers.add(100); // 추가,수정작업 X
List<Integer> num = new ArrayList<>();
num.add(100);
num.add(101);
num.add(102);
numbers = num; // 값이 있는 객체를 참조시킨다.
System.out.println(numbers.get(0));
printList1(num);
}
// 다형성 활용하기
// List에 저장되면 타입간에 계층구조가 깨짐 -> 와일드카드로 처리하면 계층구조 유지
public static void printList1(List<? extends Number> list) {
Integer i = (Integer) list.get(0);
System.out.println(i);
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
Hello~~~
1000
100
100
strBox·intBox는 같은 클래스에 서로 다른 타입(String·Integer)을 담았습니다. numbers.get(0)과 printList1(num) 둘 다 100을 출력하는데, numbers는 List<? extends Number>로 읽기는 되지만numbers.add(100)처럼 쓰기는 컴파일 에러가 난다는 걸 주석으로 확인했습니다.
12일차에 배운 것 — 핵심 정리
정적 내부 클래스는 바깥 객체 없이 생성 가능, static 필드만 접근한다.
인스턴스 내부 클래스는 바깥객체.new 내부클래스()로 생성하며 바깥의 인스턴스 필드까지 접근한다.
지역 내부 클래스가 참조하는 지역변수는 사실상 final(값이 안 바뀜)이어야 한다.
익명 클래스는 이름 없이 그 자리에서 즉시 구현하는 일회성 클래스다.
코드를 다시 볼 때 메서드 이름과 실제 동작이 일치하는지 의심하는 습관이 중요하다 — sumRow/sumCol처럼 이름이 뒤바뀌어도 컴파일은 되기 때문에 버그를 못 잡는다.
추상 클래스의 공통 로직(magicPrint·isCheck 등)을 부모로 끌어올리면 자식은 다른 부분(make())만 구현하면 된다.
짝수 마방진: 1~n²을 순서대로 채운 뒤, 정해진 사분면 영역만 반대 값(n²+1-원래값)으로 덮어쓴다.
제네릭 클래스 <T>는 사용하는 시점에 실제 타입이 정해진다.
List<? extends Number> 같은 와일드카드는 읽기는 되지만 추가(쓰기)는 컴파일 에러 — 정확한 하위 타입을 컴파일러가 모르기 때문이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
main에서 new StaticInnerClass(...)는 바로 되는데, new InnerClass("내부클래스")라고만 쓰면 왜 컴파일 에러일까?
인스턴스 내부 클래스 객체는 자기를 감싼 바깥 객체에 붙어 있어야 그 필드 a·b를 읽을 수 있습니다. 그런데 main은 static 메서드라 바깥 객체를 가리킬 this가 없습니다. 그래서 new D1_NestedClassTest().new InnerClass(...)처럼 바깥 객체를 먼저 만들어 붙여 줘야 하고, 바깥 객체가 필요 없는 정적 내부 클래스는 그냥 new로 됩니다.
11일차 MagicSquare는 sumRow·sumCol 이름이 뒤바뀌어 있었는데, 왜 isCheck()는 그때도 true가 나왔을까?
isCheck()는 가로 합 n개와 세로 합 n개, 대각선 2개를 배열 ma에 모두 넣고 전부 같은지만 봅니다. 이름이 바뀌어도 가로 합과 세로 합이 배열 안에서 자리만 바뀔 뿐 모두 들어가므로 검증 결과는 같습니다. 컴파일러는 이름의 뜻을 모르고 결과도 우연히 맞으니, 이런 버그는 이름과 동작을 직접 대조해야만 잡힙니다.
List<? extends Number> numbers에서 numbers.get(0)은 되는데 numbers.add(100)은 왜 막힐까?
numbers가 실제로 List<Integer>인지 List<Double>인지 컴파일러가 알 수 없기 때문입니다. 꺼낼 때는 무엇이 들어 있든 Number로 받을 수 있어 안전하지만, 넣을 때는 Integer 100이 Double 리스트에 들어갈 위험이 있어 막습니다. 그래서 ? extends는 읽기 전용처럼 쓰입니다.
💼 실무·코딩테스트에서는제네릭은 실무에서 매일 씁니다 — DB 조회 결과를 담는 List<BoardDto>, 설정·응답용 Map<String, Object>, 그리고 어떤 데이터든 감싸는 공통 응답 상자 ApiResponse<T>가 오늘의 D3_GBox<T>와 같은 구조입니다. 정적 내부 클래스는 빌더(User.Builder)나 DTO 안의 작은 DTO에 자주 쓰고, 바깥 필드가 필요 없는 내부 클래스는 static으로 두는 것이 관례입니다(바깥 객체를 붙들고 있지 않도록). 코딩테스트에서는 HashMap<String, Integer>처럼 타입을 정해 쓰는 컬렉션이 바로 제네릭이고(완주하지 못한 선수), 면접에서는 와일드카드 extends/super의 차이를 묻기도 합니다.
TIP오늘 마방진 리팩터링은 "컴파일이 되는 것"과 "이름이 맞는 것"은 다르다는 걸 보여주는 좋은 예입니다. sumRow가 사실은 세로 합을 계산해도 자바는 에러를 내지 않습니다 — 메서드 이름은 그냥 식별자일 뿐, 실제 의미는 사람이 붙인 주석과 문서로만 전달되니까요. isCheck() 안에서 ma[i] = sumRow(i)처럼 이름이 잘못된 메서드를 그대로 호출해도 결과가 우연히 맞을 수 있다는 점(가로세로 합을 전부 비교하는 로직이라 순서가 바뀌어도 검증 자체는 통과)도 이런 종류의 버그가 오래 숨어 있기 쉬운 이유입니다. 코드 리뷰에서 이름과 동작을 한 번씩 대조해 보는 습관을 들이세요.
2026-08-20 · 컬렉션 프레임워크 시작 — List · Set · Map · 카드 52장 실습
ListSetMapHashSetHashMapIteratorequals/hashCode
한 줄 요약7일차의 "배열은 크기가 고정"이라는 한계를 넘어 크기가 자동으로 늘어나는 컬렉션을 배운 날 — List(순서·중복 O), Set(중복 X), Map(key-value)의 기본 사용법을 각각 실습했고, 카드 52장을 중복 없이 만드는 실습으로 equals()·hashCode()를 재정의하지 않으면 contains()의 중복 검사가 왜 무력해지는지 직접 확인했다.
쉽게 말하면배열이 칸 수를 미리 정해 둔 서랍장이라면, 컬렉션은 필요할 때마다 칸이 늘어나는 서랍장입니다. List는 순서대로 쌓는 서랍(같은 물건 여러 개 가능), Set은 같은 물건은 하나만 받는 바구니, Map은 이름표(key)를 붙여 찾는 사물함입니다. 카드 실습에서 equals()를 재정의하지 않으면 contains()가 "겉모습이 같아도 다른 상자"라고 착각해서 중복 카드가 그대로 섞여 들어갑니다.
① D1_ListTest — 제네릭 없는 List(형변환 필요) vs 제네릭 List<String>
List list1 = new ArrayList();처럼 제네릭 없이 선언하면 모든 값이 Object로 저장돼 꺼낼 때 (String) list1.get(0)처럼 매번 형변환이 필요합니다. List<String> list2로 쓰면 이 과정이 사라집니다. add(idx, 값)은 특정 위치에 끼워 넣고, remove(idx)는 그 위치를 지우며, contains()는 값이 있는지 검색합니다. 마지막엔 8일차의 D1_Lotto 객체를 List<D1_Lotto>에 직접 담아 봤습니다 — 배열 대신 리스트에 객체를 담는 것도 똑같이 자연스럽다는 걸 보여줍니다. → 🛠️ 01. 컬렉션 프레임워크 개요 · 🛠️ 02. List 계열
② D1_SetTest — HashSet은 중복을 자동으로 걸러낸다
set.add("제")를 두 번 호출해도 실제로는 한 번만 저장됩니다 — Set은 애초에 "같은 값을 두 번 못 넣는" 컬렉션이기 때문입니다. 순회는 두 가지로 해 봤는데, 향상된 for문(for (String s : set))이 짧고, Iterator(hasNext()로 확인하고 next()로 꺼내는 방식)는 순회 중에 안전하게 제거가 필요할 때 쓰는 더 원초적인 방법입니다. List<String> list = new ArrayList<>(set);처럼 Set을 List로 바꾸면 인덱스로 접근할 수 있게 됩니다(Set 자체는 순서 개념이 없어 get(i)가 없으니까요). → 🛠️ 03. Set 계열
③ D1_MapTest — key로 찾는 저장소, key는 중복 불가
map.put("셋", "교육센터")를 두 번 호출해도 key "셋"의 값이 갱신될 뿐 항목이 늘지 않습니다 — Map은 key 중복을 허용하지 않기 때문입니다. 전체 데이터를 훑을 때는 map.keySet()으로 key만 모은 Set을 얻은 뒤, Iterator나 향상된 for문으로 그 key들을 하나씩 꺼내 map.get(key)로 값을 가져오는 두 가지 방식을 비교했습니다. → 🛠️ 04. Map 계열
D2_CardCase.shuffle()은 카드를 계속 뽑아 cards.contains(cc)로 이미 있는 카드인지 확인하고, 없을 때만 추가하는 식으로 52장을 중복 없이 채웁니다. 이게 실제로 작동하려면 D2_Card가 equals()를 "카드 문자열(card 필드)이 같으면 같은 카드"로 재정의해야 합니다 — 재정의하지 않으면 Object의 기본 equals(주소 비교)가 쓰여서 모양이 같은 카드도 항상 "다른 카드"로 판정되어 중복 방지가 무력해집니다. hashCode()도 함께 재정의한 이유는 "equals를 재정의하면 hashCode도 재정의한다"는 규약 때문입니다 — 4일차에 배운 내용을 실전에서 그대로 써먹은 예입니다. → 🧱 02. Object 클래스와 4대 메서드 · 🧪 12번 카드 만들기
package hk.edu20260820.day13;
import java.util.ArrayList;
import java.util.List;
import hk.edu20260812.day08.D1_Lotto;
public class D1_ListTest {
public static void main(String[] args) {
// 제네릭 없이 선언 -> 전부 Object로 저장됨
List list1 = new ArrayList();
list1.add("A");
String a = (String) list1.get(0); // String <-- Object : 형변환해줘야 함
System.out.println(a);
// 제네릭을 적용하자
List<String> list2 = new ArrayList<>();
list2.add("A");
list2.add("B");
list2.add("C");
list2.add("D");
System.out.println(list2.toString());
for (int i = 0; i < list2.size(); i++) {
System.out.println(list2.get(i));
}
System.out.println(list2.contains("A")); // 리스트값 검색
// 중간에 값을 추가하는 작업들이 많으면 ArrayList는 성능 효율이 낮다.
list2.add(1, "F"); // 1번째 인덱스에 "F"를 추가
list2.remove(0); // 0번째 인덱스의 값을 제거
// 로또객체 저장한다면
List<D1_Lotto> lottoList = new ArrayList<>();
lottoList.add(new D1_Lotto());
lottoList.add(new D1_Lotto());
lottoList.add(new D1_Lotto());
for (int i = 0; i < lottoList.size(); i++) {
System.out.println(lottoList.get(i));
}
}
}
public class D1_SetTest {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
set.add("한");
set.add("국");
set.add("경");
set.add("제");
set.add("제"); // 중복 -> 무시된다
// 향상된 for문
for (String s : set) {
System.out.println(s);
}
// Iterator
Iterator<String> iter = set.iterator(); // Set -> Iterator 객체로 변환
while (iter.hasNext()) {
String str = iter.next();
System.out.println(str);
}
// 꺼내는 다른 방법: List로 변환 -> 원하는값만 가져올 수 있다.
List<String> list = new ArrayList<>(set);
System.out.println(list.get(2));
}
}
public class D1_MapTest {
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("하나", "한경");
map.put("둘", "닷컴");
map.put("셋", "교육센터");
map.put("셋", "교육센터"); // key는 중복할 수 없다.
System.out.println(map.get("하나") + "," + map.get("둘"));
// Map에서 일괄적으로 데이터를 가져오려면
Set<String> setKeyMap = map.keySet(); // key들만 Set에 담아 반환
// 1. Iterator 사용
Iterator<String> iter = setKeyMap.iterator();
while (iter.hasNext()) {
System.out.println(map.get(iter.next()));
}
// 2. 향상된 for문 사용
for (String s : setKeyMap) {
System.out.println(map.get(s));
}
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
--- D1_ListTest ---
A
[A, B, C, D]
A
B
C
D
true
hk.edu20260812.day08.D1_Lotto@27716f4
hk.edu20260812.day08.D1_Lotto@8efb846
hk.edu20260812.day08.D1_Lotto@2a84aee7
--- D1_SetTest ---
한
제
국
경
한
제
국
경
국
--- D1_MapTest ---
한경,닷컴
닷컴
한경
교육센터
닷컴
한경
교육센터
D1_ListTest는 list2.contains("A")가 true이고, D1_Lotto 객체 3개는 toString()을 재정의하지 않아 주소@해시코드 형태로 찍힙니다(8일차 복습 포인트). D1_SetTest는 "제"를 두 번 넣어도 4개(한·제·국·경)만 저장되고, 순서는 입력한 순서가 아니라 해시 기반 순서라는 게 향상된 for문과 Iterator 두 결과가 똑같다는 것으로 확인됩니다 — list.get(2)는 이 순서의 세 번째인 "국"입니다. D1_MapTest는 put("셋", ...)을 두 번 해도 값이 3개(하나·둘·셋)뿐이라는 걸 보여줍니다.
첫 줄 [◆5]는 main에서 따로 만든 카드 한 장이고, ===== 아래가 덱입니다. 덱은 10장씩 5줄 + 마지막 2장으로 총 52장입니다. 중복이 없다는 근거는 특정 카드 하나를 눈으로 찾는 게 아니라 구조에 있습니다 — 가능한 카드가 정확히 52종(4무늬 × 13)이고, shuffle()은 contains()가 false일 때만 추가하면서 52장이 찰 때까지 돕니다. ArrayList.contains()는 내부에서 equals()로 비교하므로, equals()를 카드 내용 비교로 재정의해 둔 덕분에 같은 카드가 두 번 들어갈 수 없고 결국 52종이 각각 정확히 한 번씩 들어갑니다(hashCode()는 HashSet·HashMap에서 쓰이는 짝이라 재정의 규약상 함께 고친 것입니다). 직접 확인하려면 new HashSet<>(cards).size()가 52인지 찍어 보세요.
제네릭 없이 List를 쓰면 모든 값이 Object가 되어 꺼낼 때마다 형변환이 필요하다.
순회 방법: 향상된 for문(간단) 또는 Iterator(hasNext()+next(), 순회 중 제거가 필요할 때).
Map 전체 순회는 keySet()으로 key의 Set을 얻은 뒤 그 key로 get()한다.
List.contains()·Set의 중복 검사는 내부적으로 equals()를 사용한다 — 재정의하지 않으면 주소 비교라 중복 판정이 항상 실패한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D2_Card에서 equals() 재정의를 지우면 shuffle()은 어떻게 될까?
무한 루프에 빠지지는 않지만 중복 카드가 섞인 52장이 만들어집니다. Object의 기본 equals는 주소 비교라서, 매번 new D2_Card()로 새로 만든 cc는 모양이 같아도 항상 "없는 카드"로 판정됩니다. 그래서 contains()가 늘 false가 되어 뽑는 족족 추가되고, i가 52가 되는 순간 끝납니다.
D1_SetTest에서 "제"를 두 번 넣었는데 왜 4개만 찍히고, 순서는 왜 입력한 순서(한·국·경·제)가 아닐까?
HashSet은 hashCode()로 자리를 찾고 equals()로 같은지 확인하는데, String은 둘 다 내용 기준으로 재정의되어 있어 두 번째 "제"는 같은 값으로 판정되어 무시됩니다(add()가 false를 돌려줌). 순서는 해시값으로 정해진 내부 자리 순서라 입력 순서와 무관합니다. 입력 순서가 필요하면 LinkedHashSet, 정렬 순서가 필요하면 TreeSet을 씁니다.
D2_Card에서 equals()만 남기고 hashCode()를 지우면, List.contains()와 HashSet은 각각 어떻게 될까?
ArrayList.contains()는 equals()만 쓰므로 여전히 중복을 잘 걸러 냅니다. 하지만 HashSet·HashMap은 hashCode()로 먼저 칸을 찾은 뒤 그 칸 안에서만 equals()로 비교하는데, Object의 기본 hashCode()는 객체마다 달라서 같은 카드가 서로 다른 칸에 들어가 중복으로 저장될 수 있습니다. 그래서 "equals를 재정의하면 hashCode도 재정의한다"가 규약입니다 — equals가 판정 기준이고 hashCode는 빠르게 찾기 위한 보조값입니다.
💼 실무·코딩테스트에서는실무 자바 코드에서 가장 자주 보는 타입이 List<Dto>입니다 — DB에서 조회한 여러 행을 DTO 하나씩에 담아 리스트로 화면에 넘기고, 코드표·설정값처럼 이름으로 찾는 데이터는 Map에 둡니다. 선언은 List<String> list = new ArrayList<>()처럼 인터페이스 타입으로 하는 것이 관례이고, 값 비교용 클래스는 Java 16부터 record로 만들면 equals·hashCode를 자동으로 만들어 줍니다. 코딩테스트에서는 "중복 제거 → HashSet"(폰켓몬), "개수 세기 → HashMap + getOrDefault"(완주하지 못한 선수)가 단골이고, 면접에서는 "equals와 hashCode를 왜 같이 재정의하나", "List·Set·Map의 차이"를 자주 묻습니다.
TIPD2_Card.hashCode()가 card.hashCode() + 137처럼 임의의 숫자(137)를 더한 것이 눈에 띕니다. hashCode()의 규약은 "같은 equals 결과라면 같은 값"만 지키면 되고, 정확히 어떤 계산식을 쓰는지는 자유입니다 — card.hashCode()만 써도 되지만, 상수를 더해 "이 클래스만의" 해시 분포를 살짝 바꿔 보는 것도 흔한 습관입니다. 다만 반대로 서로 다른 카드인데 우연히 hashCode()가 같아도 괜찮습니다 — HashSet·HashMap은 해시가 같으면 그다음에 equals()로 한 번 더 확인하기 때문입니다. "해시가 같다 = 반드시 같은 객체"가 아니라 "해시가 다르면 반드시 다른 객체"라는 방향으로만 성립한다는 걸 기억하세요.
한 줄 요약12일차의 홀수·짝수(4의 배수) 마방진에 이어 4로 나누면 2가 남는 크기(6, 10, 14...)의 마방진을 4개 영역으로 쪼개 채우는 방식으로 완성했고, 완성된 세 가지 마방진(홀수·짝수·6마방진)을 D2_MagicFactory 하나로 묶어 사용자가 입력한 숫자에 맞는 객체를 골라 돌려주는 싱글턴 + 팩토리 패턴을 만들었으며, D1_MagicUtil.magicRun()이라는 공통 실행 헬퍼(템플릿 메서드와 비슷한 발상)로 "어떤 마방진이든 make() 다음 magicPrint()"라는 공통 실행 순서를 한 곳에 모았다.
쉽게 말하면오늘은 마방진 자판기를 만든 날입니다. 지금까지는 "홀수용 기계", "짝수용 기계"를 각각 따로 작동시켰다면, 오늘은 숫자를 넣으면 알맞은 기계를 대신 골라 작동시켜 주는 자판기(MagicFactory)를 만들었습니다. 자판기는 세상에 하나만 있으면 되니까 싱글턴이고, "어떤 상품이든 꺼내서(make) 보여주는(magicPrint) 순서는 똑같다"는 걸 MagicUtil이라는 공통 사용 설명서 한 장으로 정리했습니다.
① D1_SixMagicSquare — 4영역으로 쪼개 채우는 LUX 방식
6·10·14처럼 4로 나누면 나머지가 2인 크기(싱글 이븐)는 홀수·짝수 마방진보다 까다로워서 4단계로 나눠 채웁니다. makeA()·makeB()가 왼쪽 위/오른쪽 위 두 영역을 각각 3과 1·2 패턴으로 채우고, makeCD()가 그 값을 이용해 아래쪽 두 영역(C·D)을 대칭적으로 채우며(3↔0, 1↔2 맞바꾸기), multi()가 전체에 (n/2)²를 곱해 자리를 벌려 놓고, 마지막 makeAdd()가 이미 만들어 둔 OddMagicSquare(n/2 크기)의 값을 네 영역에 똑같이 더해 완성합니다 — "새로 만들지 않고 이미 있는 걸 재사용한다"는 게 핵심입니다. → 🧪 9번 마방진
② D2_MagicFactory — 싱글턴 + 팩토리 패턴
생성자를 private으로 막고 getInstance()로만 얻게 한 건 5일차에 배운 싱글턴 그대로입니다. 여기에 factory() 메서드가 새로운 역할을 더합니다 — Scanner로 숫자를 입력받아 num % 2 == 1이면 OddMagicSquare, num % 4 == 0이면 EvenMagicSquare, num % 4 == 2이면 D1_SixMagicSquare를 알아서 골라 만들어 반환합니다. 반환 타입이 Interface_Magic이라 호출하는 쪽은 어떤 구체 클래스가 나왔는지 몰라도make()·magicPrint()를 그대로 쓸 수 있습니다 — 이게 팩토리 패턴입니다("객체 생성을 대신 해 주고, 사용하는 쪽은 인터페이스만 본다"). 주의 — 팩토리가 new D1_SixMagicSquare(num)으로 만드는 건 같은 패키지에 있는 day11_2의 상속판(MagicSquare를 상속하므로 Interface_Magic 구현)입니다. 위 코드 블록에 실린 day14 독립형은 아무것도 상속·구현하지 않아 Interface_Magic 타입으로 반환될 수 없습니다(알고리즘을 따로 연습한 버전). → 🧱 11. 싱글턴 패턴 · 🧱 22. 인터페이스
③ D1_MagicUtil — 공통 실행 헬퍼로 실행 순서를 한 곳에
수업 주석은 이걸 "템플릿 메서드"라고 불렀지만, 정식 템플릿 메서드 패턴은 부모(추상) 클래스 안의 메서드가 실행 순서를 정하고 자식이 오버라이딩한 단계 메서드를 부르는 구조입니다. magicRun()은 바깥 유틸 클래스의 static 메서드라 정확히는 공통 실행 헬퍼이고, "순서를 한 곳에 고정한다"는 발상만 비슷합니다. magicRun(Interface_Magic magic)은 딱 두 줄, magic.make(); magic.magicPrint();뿐입니다. 별거 아닌 것 같지만, 어떤 마방진이 오든 "만들고 → 찍는다"는 순서는 항상 같다는 사실을 한 메서드로 못박아 둔 것입니다. D3_MagicSquareMain은 이제 if~else로 타입을 구분하지 않고 D1_MagicUtil.magicRun(magic) 한 줄만 호출합니다 — 파라미터를 인터페이스 타입으로 선언했기 때문에(10일차 다형성) 어떤 구현체가 와도 똑같이 동작합니다. → 🧱 19. 오버라이딩과 VMI
package hk.edu20260821.day14;
import hk.edu20260818.day11.day11_2.OddMagicSquare;
public class D1_SixMagicSquare {
private int[][] magic;
public D1_SixMagicSquare() {
this(6);
}
public D1_SixMagicSquare(int n) {
this.magic = new int[n][n];
}
public void make() {
makeA();
makeB();
makeCD();
multi();
makeAdd();
}
// A영역: i가 2일 땐 j+1 위치에, 그 외엔 j 위치에 3을 채운다
private void makeA() {
int n = magic.length;
for (int i = 0; i < n / 2; i++) {
for (int j = 0; j < n / 4; j++) {
if (i == 2) magic[i][j + 1] = 3;
else magic[i][j] = 3;
}
}
}
// B영역: 먼저 1로 채우고, 그 범위만큼 2로 덮어쓴다
private void makeB() {
int n = magic.length;
for (int i = 0; i < n / 2; i++) {
for (int j = 0; j < n / 2; j++) {
magic[i][j + n / 2] = 1;
}
}
for (int i = 0; i < n / 2; i++) {
for (int j = 0; j < n / 2 - (n / 4 - 1); j++) {
magic[i][j + n / 2] = 2;
}
}
}
// C영역: A의 3<->0을 맞바꿔 아래로. D영역: B의 1<->2를 맞바꿔 아래로
private void makeCD() {
int n = magic.length;
for (int i = 0; i < n / 2; i++) {
for (int j = 0; j < n / 2; j++) {
if (magic[i][j] == 3) magic[i + n / 2][j] = 0;
else if (magic[i][j] == 0) magic[i + n / 2][j] = 3;
if (magic[i][j + n / 2] == 1) magic[i + n / 2][j + n / 2] = 2;
else if (magic[i][j + n / 2] == 2) magic[i + n / 2][j + n / 2] = 1;
}
}
}
// 전체 값에 (n/2)*(n/2)를 곱해 자리를 벌린다
private void multi() {
int n = magic.length;
int m = (n / 2) * (n / 2);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
magic[i][j] *= m;
}
}
}
// 이미 만들어 둔 홀수 마방진(n/2 크기)을 네 영역에 똑같이 더한다
private void makeAdd() {
int n = magic.length;
OddMagicSquare odd = new OddMagicSquare(n / 2);
odd.make();
int[][] oddMagic = odd.getMagic();
for (int i = 0; i < n / 2; i++) {
for (int j = 0; j < n / 2; j++) {
magic[i][j] += oddMagic[i][j];
magic[i][j + n / 2] += oddMagic[i][j];
magic[i + n / 2][j] += oddMagic[i][j];
magic[i + n / 2][j + n / 2] += oddMagic[i][j];
}
}
}
public static void main(String[] args) {
D1_SixMagicSquare six = new D1_SixMagicSquare(10);
six.make();
for (int i = 0; i < six.magic.length; i++) {
for (int j = 0; j < six.magic.length; j++) {
System.out.print(six.magic[i][j] + "\t");
}
System.out.println();
}
}
}
실행 결과10×10으로 실행(가로·세로·대각선 합 검증 포함 버전은 day11_2 상속판 기준) 먼저 예측 → 펼쳐서 확인
day11_2의 MagicSquare를 상속한 버전으로 가로·세로·대각선 합을 전부 검증해 보면 10×10 기준 505로 정확히 일치합니다(1~100의 합 5050 ÷ 10). 다만 6×6은 실패했습니다 — 직접 돌려 보니 가로·세로 합은 전부 111로 맞았지만 대각선 합은 84와 165로 서로 달라 isCheck()가 false가 나왔습니다. 원인은 알고리즘이 아니라 코드의 버그입니다. makeA()의 if (i == 2)는 "A영역 가운데 줄만 한 칸 옆으로 민다"는 뜻인데, 가운데 줄 번호를 2로 고정해 버려서 가운데 줄이 정확히 2인 n=10에서만 맞습니다. 원본 주석("i인덱스의 n/4이 되는 위치")대로 if (i == n / 4)로 고치면 6×6(대각선 111·111), 14×14, 18×18까지 전부 마방진이 됩니다 — 고치기 전에는 14×14·18×18도 대각선이 틀립니다.
JAVAD2_MagicFactory.java + D1_MagicUtil.java + D3_MagicSquareMain.java (day11_2, 수업 실습 원본)
// ※ 파일 세 개를 이어 붙인 블록입니다. public class 하나당 파일 하나이므로
// 복사할 때는 클래스별로 나눠 저장하세요(같은 package 선언을 각 파일 맨 위에).
package hk.edu20260818.day11.day11_2;
import java.util.Scanner;
public class D2_MagicFactory {
// singleton pattern 적용
private static D2_MagicFactory magicFactory;
private D2_MagicFactory() { }
public static D2_MagicFactory getInstance() {
if (magicFactory == null) {
magicFactory = new D2_MagicFactory();
}
return magicFactory;
}
// Factory pattern: 원하는 마방진 요청을 확인해서 해당 객체를 반환
public Interface_Magic factory() {
Interface_Magic magic = null;
System.out.println("원하는 마방진을 입력하세요(숫자형식)");
Scanner scan = new Scanner(System.in);
int num = scan.nextInt();
if (num < 3) {
System.out.println("3이상 숫자를 입력하세요");
} else if (num % 2 == 1) {
magic = new OddMagicSquare(num);
} else if (num % 4 == 0) {
magic = new EvenMagicSquare(num);
} else if (num % 4 == 2) {
magic = new D1_SixMagicSquare(num);
}
scan.close();
return magic;
}
}
public class D1_MagicUtil {
// 템플릿 메서드: 어떤 마방진이든 "만들고 -> 찍는다" 순서는 동일
public static void magicRun(Interface_Magic magic) {
magic.make();
magic.magicPrint();
}
}
public class D3_MagicSquareMain {
public static void main(String[] args) {
// 메서드를 통해 객체 얻어옴: new 사용 못함(생성자가 private)
D2_MagicFactory fac = D2_MagicFactory.getInstance();
Interface_Magic magic = fac.factory();
if (magic == null) {
System.out.println("다시입력하세요");
} else {
D1_MagicUtil.magicRun(magic);
}
}
}
"10"을 입력하면 10 % 4 == 2라 factory()가 D1_SixMagicSquare를 만들어 돌려주고, magicRun()이 make() → magicPrint() 순서로 실행해 가로·세로·대각선 전부 505로 검증까지 통과합니다(이때 만들어지는 건 day11_2의 상속판D1_SixMagicSquare입니다). 끝에서 두 번째 ma결과:[…] 줄은 위 코드가 아니라 부모 MagicSquare.isCheck() 안의 System.out.println("ma결과:" + Arrays.toString(ma));가 찍은 것입니다(12일차 카드의 MagicSquare 코드 참고). "3","5" 같은 홀수를 넣으면 같은 코드가 OddMagicSquare를, "8","12" 같은 4의 배수를 넣으면 EvenMagicSquare를 대신 돌립니다 — D3_MagicSquareMain은 어떤 경우든 코드를 한 줄도 바꾸지 않습니다.
14일차에 배운 것 — 핵심 정리
마방진 크기 3분류: 홀수 / 짝수(4의 배수) / 싱글 이븐(4로 나누면 2가 남음) — 알고리즘이 전부 다르다.
싱글 이븐은 4영역으로 나눠 채운 뒤, 이미 만든 홀수 마방진 값을 더해 완성한다(새로 만들지 않고 재사용).
팩토리 패턴 = 조건에 따라 알맞은 객체를 대신 생성해서 인터페이스 타입으로 반환하는 설계 — 호출하는 쪽은 구체 클래스를 몰라도 된다.
싱글턴 + 팩토리를 같이 쓰면 "객체 만드는 공장은 하나만 있으면 된다"는 게 자연스럽게 성립한다.
magicRun() 같은 공통 실행 헬퍼 = 여러 구현체에 공통되는 실행 순서를 메서드 하나로 고정해 둔 것(정식 템플릿 메서드 패턴은 이 순서를 부모 클래스 안에 두고 자식이 단계를 오버라이딩한다).
파라미터를 인터페이스 타입으로 선언하면(magicRun(Interface_Magic magic)) 다형성 덕분에 if~else 분기 없이 모든 구현체를 같은 코드로 처리할 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D3_MagicSquareMain에는 if~else가 없는데, 어떻게 3·8·10을 넣을 때마다 다른 마방진이 만들어질까?
어떤 클래스를 만들지는 factory() 안의 분기 한 곳에서 정하고, 바깥으로는 Interface_Magic 타입으로만 돌려주기 때문입니다. magicRun()이 magic.make()를 부르면 자바는 변수 타입이 아니라 실제 객체의 오버라이딩된 메서드를 실행합니다(다형성). 그래서 새 마방진 종류가 생겨도 고칠 곳은 factory() 하나뿐입니다.
10×10은 505로 검증을 통과하는데, 6을 넣으면 왜 대각선 검증이 실패할까?
makeA()의 if (i == 2)가 n=10일 때의 가운데 줄 번호를 박아 둔 값이기 때문입니다. 가운데 줄은 n / 4라서 n=6이면 1, n=14면 3인데 항상 2번 줄을 밀어 버리니 대각선 합이 어긋납니다. if (i == n / 4)로 고치면 6·10·14·18 모두 통과합니다 — 입력 하나로만 테스트하면 이런 매직 넘버 버그가 숨는다는 교훈입니다.
D2_MagicFactory.getInstance()를 두 번 부르면 객체가 몇 개 생길까? 스레드 두 개가 동시에 처음 부르면?
한 스레드에서 부르면 첫 호출 때만 new가 실행되고 두 번째부터는 null이 아니라 같은 객체를 돌려주므로 1개입니다(fac1 == fac2가 true). 하지만 두 스레드가 거의 동시에 if (magicFactory == null)을 통과하면 둘 다 new를 해서 2개가 생길 수 있습니다. 그래서 여러 스레드 환경에서는 getInstance()에 synchronized를 붙이거나, 필드 선언과 동시에 객체를 만들어 두는 방식을 씁니다(18일차 동기화와 이어지는 이야기).
💼 실무·코딩테스트에서는실무에서는 "하나만 만들어 공유하는 객체"와 "알맞은 구현체를 골라 주는 공장"을 스프링 컨테이너가 대신 맡습니다 — 스프링 빈은 기본이 싱글턴이라 getInstance()를 직접 짤 일은 드물고, 결제 수단·파일 저장소처럼 구현체가 여러 개인 기능을 인터페이스로 받아 바꿔 끼우는 설계가 오늘 팩토리의 실전판입니다. 면접에서는 "싱글턴 패턴을 구현하고 단점(멀티스레드 안전성, 테스트하기 어려움)을 말해 보라", "다형성이 무엇이고 어디에 쓰나"가 단골입니다. 코딩테스트에서는 마방진처럼 2차원 배열의 인덱스를 n에 맞춰 계산하는 문제가 자주 나옵니다(행렬의 덧셈) — 숫자를 박지 말고 n으로 표현하는 습관이 그대로 필요합니다.
TIP6×6이 대각선 검증에 실패한 건 알고리즘의 한계가 아니라 하드코딩 버그입니다 — makeA()의 if (i == 2)는 "A영역의 가운데 줄"을 가리켜야 하는데 그 값이 n마다 다릅니다(n=6이면 1, n=10이면 2, n=14이면 3). 힌트: i == n / 4로 바꾸면 6·10·14·18 모두 통과합니다. 수업에서 10×10으로만 돌려 봤기 때문에 숨어 있던 버그로, "테스트한 한 가지 입력에서 잘 동작한다"고 해서 모든 크기에서 맞는 건 아니라는 것, 그리고 예제 값에 맞춘 숫자(매직 넘버)를 코드에 박아 두면 다른 입력에서 깨진다는 것을 이 사례로 기억해 두면 좋습니다 — isCheck()처럼 결과를 스스로 검증하는 코드를 같이 짜 두면 이런 예외를 바로 발견할 수 있습니다.
2026-08-24 · 예외 처리 — try·catch·finally · 사용자 정의 예외 · 패키지 정리
try-catch-finally다중 catch사용자 정의 예외throw/throwstry-with-resources
한 줄 요약13일차 컬렉션에 이어 프로그램이 잘못될 수 있는 지점을 붙잡아 처리하는 예외 처리를 배운 날 — 여러 종류의 예외를 타입별로 나눠 catch하고 finally로 성공·실패 상관없이 항상 실행되는 코드를 만드는 법, Exception을 상속해 나만의 예외 클래스를 만들고 throw로 던지는 법을 정리했고, 13일차의 카드 클래스를 cardgame이라는 전용 패키지로 옮겨 정리했다.
쉽게 말하면예외 처리는 "깨질 수 있는 부분에 미리 안전망을 쳐 두는 것"입니다. try는 안전망 위에서 일을 시도하는 구역, catch는 떨어졌을 때 받아 주는 그물(그물 종류별로 다르게 받을 수 있음), finally는 떨어지든 안 떨어지든 무조건 마지막에 하는 뒷정리입니다. 사용자 정의 예외는 "이 상황엔 이런 이름의 문제가 생겼다"고 직접 이름 붙인 그물을 만드는 것입니다.
① exTest2 — 예외 종류별로 catch를 나누고, finally는 항상 실행
하나의 try 블록 안에서 Integer.parseInt()(숫자 변환 실패)·substring()(문자열 범위 초과)·배열 인덱스 초과가 모두 일어날 수 있는데, catch를 NumberFormatException → StringIndexOutOfBoundsException → ArrayIndexOutOfBoundsException → Exception 순서로 구체적인 것부터 포괄적인 것 순으로 나열했습니다. 순서가 중요한 이유는 먼저 맞는 catch 하나만 실행되기 때문입니다(더 넓은 Exception을 앞에 두면 뒤의 구체적인 catch들은 절대 실행될 수 없으므로 컴파일 에러("exception ... has already been caught")가 납니다). finally 블록은 예외가 나든 안 나든 무조건 실행되어 뒷정리(자원 해제 등)를 맡습니다. → 🛠️ 05. 예외 처리 — try·catch·finally
② D1_UserException — Exception을 상속해 나만의 예외 만들기
class D1_UserException extends Exception으로 선언하고, 기본 생성자가 this("UserException 오류 입니다.")로 메시지 있는 생성자에 위임하며, 그 생성자는 super(msg)로 부모(Exception)에게 메시지를 전달합니다(6·8일차에 배운 this(...)/super(...) 패턴 그대로). UserExceptionTest(int a)는 throws D1_UserException을 메서드 선언에 명시하고, 조건에 안 맞으면 throw new D1_UserException("...")으로 직접 예외를 던집니다. 이렇게 만든 예외는 자바 기본 예외들과 똑같이 try~catch로 잡을 수 있습니다. → 🛠️ 06. 예외 계층과 사용자 정의 예외
③ exTest4 — try-with-resources로 close() 자동 처리
try (InputStream in = new FileInputStream("url")) { ... }처럼 괄호 안에서 자원을 선언하면, try 블록이 끝날 때(정상이든 예외든) 자동으로 close()가 호출됩니다. InputStream이 AutoCloseable을 구현하고 있어 가능한 문법으로, 예전처럼 finally 안에서 직접 in.close()를 호출하며 그 자체가 또 예외를 던질 수 있는 상황을 신경 쓸 필요가 없어집니다. → 🛠️ 10. InputStream · OutputStream과 close()
④ cardgame 패키지 — 13일차 카드 클래스를 전용 패키지로 정리
D2_Card·D2_CardCase·D2_CardMain 세 파일이 13일차와 내용은 완전히 동일하고, package hk.edu20260820.day13;이 package hk.edu20260824.day15.cardgame;으로만 바뀌었습니다. 로직 변경 없이 관련 있는 클래스들을 전용 패키지로 묶어 정리하는 연습으로, 프로젝트가 커질수록 "이 파일들이 한 기능을 담당한다"는 걸 패키지 이름으로 드러내는 습관이 중요해진다는 걸 보여줍니다. → 🧱 08. package와 import · 🧪 12번 카드 만들기
package hk.edu20260824.day15;
// 내가 필요한 Exception 클래스를 만들때는 Exception을 상속받아 생성
public class D1_UserException extends Exception {
public D1_UserException() {
this("UserException 오류 입니다.");
}
public D1_UserException(String msg) {
super(msg);
}
}
public class D1_Exception {
public static void main(String[] args) {
// 예외를 던지면
try {
UserExceptionTest(12);
} catch (D1_UserException e) {
System.out.println("예외가 발생했습니다");
e.printStackTrace();
}
}
public static void exTest1(String s) {
int a = 0;
try {
a = Integer.parseInt(s); // <-- 예외가 발생될 여지가 있는 코드
} catch (NumberFormatException e) {
System.out.println("예외가 발생했습니다");
} catch (Exception ee) { // 넘버포멧익셉션 외 다른 예외는 큰 부모로 다 잡는다
ee.printStackTrace();
}
System.out.println(a);
}
public static void exTest2(String s) {
int i = 0;
String ss = "스트링";
int[] array = { 1, 2, 3, 4, 5 };
try {
i = Integer.parseInt(s);
ss = ss.substring(0, 2);
int a = array[5];
} catch (NumberFormatException e) {
System.out.println("문자가 숫자형태로 변환되지 않았습니다.");
e.printStackTrace();
} catch (StringIndexOutOfBoundsException e) {
System.out.println("문자열의 범위를 벗어났습니다.");
e.printStackTrace();
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("배열의 범위를 벗어났습니다.");
e.printStackTrace();
} catch (Exception e) {
System.out.println("나머지 모든 예외를 처리한다");
e.printStackTrace();
} finally {
ss = ss.substring(0, 2);
System.out.println(ss);
}
System.out.println("오류발생해도 프로그램은 종료되지 않는다.");
}
// try-with-resources: InputStream은 AutoCloseable이라 자동으로 close()된다
public static void exTest4() {
try (java.io.InputStream in = new java.io.FileInputStream("url")) {
in.read();
} catch (java.io.IOException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}
// 숫자를 받아서 1~10까지의 숫자만 허용, 벗어나면 예외를 던진다
public static void UserExceptionTest(int a) throws D1_UserException {
if (!(a > 0 && a < 11)) {
throw new D1_UserException("1부터 10까지의 숫자만 입력가능");
}
}
}
실행 결과main() 그대로 실행 — UserExceptionTest(12) 호출 먼저 예측 → 펼쳐서 확인
예외가 발생했습니다
hk.edu20260824.day15.D1_UserException: 1부터 10까지의 숫자만 입력가능
at hk.edu20260824.day15.D1_Exception.UserExceptionTest(D1_Exception.java:87)
at hk.edu20260824.day15.D1_Exception.main(D1_Exception.java:16)
12는 1~10 범위를 벗어나므로 UserExceptionTest(12)가 D1_UserException을 throw합니다. main의 catch (D1_UserException e)가 이를 잡아 "예외가 발생했습니다"를 출력하고, e.printStackTrace()가 예외 메시지와 발생 위치(어느 파일, 몇 번째 줄)를 자세히 찍습니다.
실행 결과참고 — exTest2("오")를 따로 호출해 보면 (main에선 호출되지 않음) 먼저 예측 → 펼쳐서 확인
문자가 숫자형태로 변환되지 않았습니다.
java.lang.NumberFormatException: For input string: "오"
at java.base/java.lang.Integer.parseInt(...)
at hk.edu20260824.day15.D1_Exception.exTest2(D1_Exception.java:43)
스트
오류발생해도 프로그램은 종료되지 않는다.
Integer.parseInt("오")에서 NumberFormatException이 터져 가장 먼저 맞는 catch가 잡고, substring()·배열 접근은 실행되지도 못한 채 건너뜁니다(예외가 나는 순간 try 블록 나머지는 전부 스킵). 그다음 finally가 무조건 실행되어 ss.substring(0,2)로 "스트"를 다시 만들고, catch·finally가 다 끝난 뒤 프로그램은 종료되지 않고 다음 줄이 계속 실행됩니다.
15일차에 배운 것 — 핵심 정리
catch는 구체적인 예외부터 포괄적인 예외(Exception) 순서로 나열해야 한다(반대로 하면 뒤 catch에 닿을 수 없어 컴파일 에러 — "already been caught").
finally는 예외 발생 여부와 상관없이 항상 실행된다 — 자원 정리에 적합하다.
예외가 발생한 순간 try 블록의 나머지 코드는 실행되지 않고 바로 catch로 넘어간다.
사용자 정의 예외는 Exception(또는 그 자손)을 extends하고, 생성자에서 super(msg)로 메시지를 부모에게 전달한다.
예외를 직접 던질 땐 throw new 예외(), 메서드가 그 예외를 던질 수 있다고 알릴 땐 throws 예외를 메서드 선언에 붙인다.
try(자원 선언) 형태(try-with-resources)를 쓰면 AutoCloseable 자원이 자동으로 close()된다.
로직 변경 없이 패키지만 옮겨 정리하는 것도 정상적인 리팩터링이다 — 관련 클래스를 한 패키지로 묶으면 구조가 더 잘 드러난다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
exTest2에서 catch (Exception e)를 맨 위로 올리면 어떻게 될까?
컴파일 에러가 납니다("exception NumberFormatException has already been caught"). catch는 위에서부터 처음 맞는 하나만 실행되는데, Exception은 모든 예외의 부모라서 아래의 구체적인 catch들은 절대 도달할 수 없는 코드가 되기 때문입니다. 그래서 항상 구체적인 예외 → 넓은 예외 순서로 씁니다.
exTest2("3")을 호출하면 어떤 catch가 잡고, 무엇이 출력될까?
parseInt("3")은 성공하고, "스트링".substring(0, 2)도 성공해 ss가 "스트"가 됩니다. 그다음 array[5]에서 터지는데 배열 길이가 5라 인덱스는 0~4뿐이므로 ArrayIndexOutOfBoundsException이 "배열의 범위를 벗어났습니다."를 찍습니다. 이어서 finally가 "스트"를 찍고, 프로그램은 멈추지 않고 "오류발생해도 프로그램은 종료되지 않는다."까지 출력합니다.
main에서 try~catch를 지우고 UserExceptionTest(12)만 남기면 어떻게 될까?
컴파일 에러(unreported exception)가 납니다. D1_UserException은 Exception을 직접 상속한 checked 예외라서, 그 예외를 던지는 메서드를 부르는 쪽은 try~catch로 잡거나 자기도 throws로 넘겨야 합니다. 만약 RuntimeException을 상속했다면 unchecked 예외라 컴파일러가 강제하지 않아 컴파일은 되고, 실행 중에 예외로 프로그램이 멈춥니다.
💼 실무·코딩테스트에서는실무에서는 printStackTrace() 대신 로그(logger)로 남기고, 사용자에게는 오류 페이지나 알아듣기 쉬운 메시지를 보여 줍니다 — 스프링에서는 예외 처리를 컨트롤러마다 흩어 두지 않고 @ControllerAdvice 한 곳에 모으며, 사용자 정의 예외는 보통 RuntimeException을 상속해 "재고 부족", "권한 없음" 같은 업무 규칙 위반을 표현합니다. 빈 catch로 예외를 삼키는 것은 장애 원인을 숨기는 대표적인 나쁜 습관입니다. 코딩테스트에서는 Integer.parseInt의 NumberFormatException을 자주 만나고(문자열을 정수로 바꾸기, 숫자 문자열과 영단어), 면접에서는 "checked와 unchecked 예외의 차이", "throw와 throws의 차이"를 자주 묻습니다.
TIPexTest2에서 substring(0, 2)가 try 안과 finally 안에 똑같이 등장합니다. 예외가 나면 try 안의 substring은 실행되지 못하고 건너뛰지만, finally 안의 같은 코드는 무조건 실행되어 "스트"라는 결과를 만들어 냅니다 — 같은 코드라도 어느 블록에 있느냐에 따라 "실행이 보장되는지"가 완전히 달라진다는 걸 보여주는 좋은 예입니다. 그리고 D1_Exception.exTest4()의 new FileInputStream("url")은 "url"이라는 실제 파일이 없으므로 열자마자 IOException(정확히는 FileNotFoundException)이 납니다 — try-with-resources는 "자원을 안전하게 닫아 주는 것"이지 "파일이 존재하게 만들어 주는 것"은 아니라는 점도 기억해 두세요.
한 줄 요약15일차 예외 처리에 이어 짧게 쓰고, 읽고 쓰고, 동시에 처리하는 문법을 훑은 날 — 익명 클래스 한 줄짜리를 람다식으로 줄이는 법, List를 Stream으로 바꿔 필터링·정렬·변환을 파이프라인으로 잇는 법(일반 스트림과 병렬 스트림 비교 포함)을 배웠고, 파일 IO를 try-with-resources로 정리한 뒤 보조 스트림(버퍼·데이터·문자 변환)까지 얹어 이미지 복사와 키보드 다이어리를 만들었으며, 마지막엔 Thread를 start()해서 여러 흐름이 동시에 실행되는 것을 처음 눈으로 확인했다.
쉽게 말하면람다식은 "포장지를 벗긴 코드"입니다 — 클래스 이름, 메서드 이름, @Override까지 다 떼어내고 "이 값들 받으면 이렇게 계산해"만 남긴 겁니다. Stream은 컨베이어 벨트고, IO의 보조 스트림은 수도관에 필터를 하나씩 끼우는 것입니다(문자로 바꾸는 필터, 모아서 보내는 필터...). 스레드는 일하는 사람(작업 흐름)을 여럿 두는 것이에요 — 순차 실행은 "안안안안안녕녕녕녕녕"처럼 한 사람이 다 끝내고 다음으로 넘어가지만, Thread를 쓰면 "안녕녕안녕안..."처럼 두 흐름이 섞여 진행됩니다. CPU 코어가 여러 개면 정말 동시에 돌고, 코어가 부족하면 OS가 아주 빠르게 번갈아(시분할) 돌려서 동시에 도는 것처럼 보입니다 — 어느 쪽이든 코드 입장에서는 "순서를 장담할 수 없다"는 점이 같습니다.
① D1_LabdaTest — 익명 클래스 → 람다식 → 더 짧은 람다식
추상 메서드가 add(int a, int b)하나뿐인 인터페이스(D1_ILambda)를 구현하는 세 가지 방법을 나란히 비교했습니다. ① 익명 클래스: new D1_ILambda() { @Override public int add(...) {...} } — 11일차에 배운 그대로, 가장 장황합니다. ② 람다식: (a, b) -> { return a + b; } — 클래스 이름·메서드 이름·@Override가 전부 사라지고 매개변수와 실행문만 남습니다. ③ 더 짧은 람다식: 실행문이 return 한 줄뿐이면 (a, b) -> a + b처럼 중괄호와 return도 생략할 수 있습니다. 셋 다 결과는 같은 3입니다 — 추상 메서드가 정확히 하나인 인터페이스(함수형 인터페이스)에서만 가능한 문법입니다. → 🛠️ 07. 람다식(Lambda) · 🧱 23. 중첩 클래스(익명 클래스)
② D2_StreamTest — filter·sorted·map으로 파이프라인 잇기, 메서드 참조 ::
list.stream().filter(s -> s.contains("김")).sorted().forEach(...)처럼 중간 연산(filter·sorted·map)을 이어 붙이고 최종 연산(forEach·collect)으로 마무리합니다. forEach(s -> System.out.println(s))가 "값을 받아서 그대로 메서드에 넘기기만" 한다면 forEach(System.out::println)로 줄일 수 있는데, 이게 메서드 참조입니다. map(String::length)도 같은 문법으로, 원본을 바꾸지 않고 새로운 값들의 리스트를 반환합니다([3, 3, 3] — "김태경"·"김상원"·"임OO" 모두 3글자. 세 번째 이름은 동료 수강생 실명이라 "임OO"로 가렸습니다). Stream은 한 번 쓰면 끝나는 파이프라서, 같은 리스트를 여러 번 다르게 처리하려면 매번 list.stream()을 새로 호출해야 합니다. → 🛠️ 08. Stream API
③ 일반 스트림 vs 병렬 스트림 — 같은 코드, 다른 실행 스레드
list3.stream().forEach(...)는 전부 main 스레드에서 순서대로 실행되지만, list3.parallelStream().forEach(...)는 ForkJoinPool의 여러 워커 스레드로 나뉘어 동시에 처리됩니다 — 그래서 출력 순서가 원본 리스트 순서와 달라질 수 있습니다(13일차 HashSet처럼 "순서가 보장되지 않는" 또 다른 예). 데이터가 많고 순서가 중요하지 않을 때는 병렬 스트림이 빠를 수 있지만, 순서를 반드시 지켜야 하는 작업에는 적합하지 않다는 게 이 비교의 핵심입니다.
④ D3_IOTest.test01 — 그대로지만 try-with-resources로 정리
1바이트씩 파일을 복사하는 로직입니다. 원본 파일에는 같은 로직을 finally에서 close()하던 예전 버전이 주석으로 남아 있고, 이번엔 InputStream·OutputStream을 try(...) 괄호 안에서 선언하도록 고쳤습니다(try-with-resources 자체는 15일차exTest4에서 처음 봤습니다). 그 결과 finally에서 close()를 직접 호출하던 코드 블록 전체가 사라졌습니다 — in.read()는 여전히 1바이트씩만 읽고 더 읽을 게 없으면 -1을 반환하므로 while ((i = in.read()) != -1)로 반복하는 구조는 그대로입니다. → 🛠️ 09. 입출력(IO) 개요 · 🛠️ 10. InputStream · OutputStream과 close()
⑤ 보조 스트림 3종 — 버퍼(BufferedWriter) · 데이터(DataOutputStream) · 문자 변환(OutputStreamWriter)
(아래 D3_IOTest 코드 블록의 test02·test02_2 참고) test02는 FileOutputStream(바이트) 위에 OutputStreamWriter(문자로 변환, "utf-8" 지정)를 끼우고, 그 위에 다시 BufferedWriter(8KB씩 모았다가 한 번에 출력)를 끼웠습니다 — 파이프에 필터를 겹겹이 씌우는 구조입니다. 주석에 남긴 3단계 비교가 핵심입니다: ① 원시 스트림(out.write(s.getBytes()))은 문자열을 직접 바이트로 쪼개야 하고, ② Writer를 끼우면 문자 단위로 알아서 처리되며, ③ Buffered까지 끼우면 한 글자마다 출력하지 않고 모아서 출력해 성능이 좋아집니다. test02_2의 DataOutputStream은 기본 데이터 타입을 그대로 이진 데이터로 내보내는 필터로(writeInt()·writeUTF() 등), 자바 프로그램끼리 데이터를 주고받을 때 씁니다. 다만 수업 코드는 writeUTF(s)를 주석 처리하고 ds.write(s.getBytes())로 바이트를 그대로 썼기 때문에, 결과 파일은 FileOutputStream만 썼을 때와 같습니다 — 필터의 특징을 보려면 writeUTF() 쪽을 써 봐야 합니다. → 🛠️ 10. InputStream · OutputStream과 close()
⑥ test03 — 이미지를 10바이트씩 묶어서 복사
1바이트씩 읽던 test01과 달리 byte[] b = new byte[10];으로 한 번에 10바이트씩 읽습니다. 여기서 in.read(b)가 반환하는 i는 "실제로 읽은 바이트 개수"인데, 파일 끝에 다다르면 배열이 10칸을 다 못 채울 수 있습니다. 이때 out.write(b)(배열 전체를 쓰기)로 하면 이전에 읽었던 찌꺼기 데이터까지 같이 써질 수 있어 위험하고, out.write(b, 0, i)(딱 읽은 개수만큼만 쓰기)가 안전합니다 — 주석에도 "안정적(권장)"이라고 정확히 짚어 뒀습니다.
⑦ test04 · test05 — 콘솔 입출력과 "다이어리" 과제(이어쓰기 모드)
InputStreamReader(System.in, "MS949")로 키보드 입력을 문자로 바꾸는데, 인코딩을 "MS949"로 명시한 이유는 윈도우 콘솔의 기본 한글 인코딩이 MS949이기 때문입니다(UTF-8로 읽으면 한글이 깨집니다). out.flush()는 버퍼에 쌓인 걸 강제로 내보내는 것인데, 콘솔 출력은 버퍼가 다 차기 전엔 안 나갈 수 있어서 매번 flush()를 호출합니다. 과제로 만든 다이어리(test05)는 BufferedReader.readLine()으로 한 줄씩 입력받아 "exit"가 나올 때까지 파일에 계속 씁니다 — new FileOutputStream(경로, true)의 두 번째 인자 true가 "이어쓰기 모드"라서, 프로그램을 다시 실행해도 기존 내용 뒤에 새 줄이 추가됩니다(덮어쓰지 않음). → ☕ Scanner
⑧ D4_ThreadTest — start()로 여러 흐름을 동시에 실행하기
먼저 for문 두 개를 순서대로 돌려 "안"을 5번, "녕"을 5번 찍었습니다 — 순차 실행이라 절대 섞이지 않습니다(안안안안안녕녕녕녕녕). 그다음 같은 일을 하는 코드를 익명 클래스로 Thread를 상속해서 만들고(new Thread() { @Override public void run() {...} }), t1.start()·t2.start()를 호출하니 "안"과 "녕"이 실행될 때마다 뒤섞여 찍힙니다 — 두 작업이 동시에 진행된다는 증거입니다(멀티코어면 실제로 동시에, 코어가 부족하면 OS가 아주 빠르게 번갈아 실행 — 어느 경우든 순서는 보장되지 않습니다). 각 스레드 안의 Thread.sleep(500)은 다음 글자를 찍기 전 0.5초씩 쉬는 것으로, 다른 스레드가 끼어들 틈을 일부러 만들어 인터리빙을 눈에 잘 띄게 한 장치입니다. 그리고 run()을 직접 호출하면 그냥 평범한 메서드 호출이라 스레드가 새로 생기지 않는다는 주석도 오늘의 함정 포인트입니다 — 반드시 start()를 불러야 합니다. → 🛠️ 11. 스레드(Thread)
package hk.edu20260825.day16;
public interface D1_ILambda {
// 메서드를 하나만 선언해서 사용한다.
public int add(int a, int b); // 추상메서드
}
public class D1_LabdaTest {
public static void main(String[] args) {
// 익명클래스방식
D1_ILambda lam = new D1_ILambda() {
@Override
public int add(int a, int b) {
return a + b;
}
};
System.out.println(lam.add(1, 2));
// 람다식 방식
D1_ILambda lam2 = (a, b) -> {
return a + b;
};
System.out.println(lam2.add(1, 2));
// 람다식 방식 간략하게
D1_ILambda lam3 = (a, b) -> a + b;
System.out.println(lam3.add(1, 2));
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
3
3
3
세 가지 방식 모두 1 + 2 = 3을 출력합니다 — 문법만 짧아질 뿐 동작은 완전히 동일하다는 걸 눈으로 확인하는 게 이 코드의 목적입니다.
JAVAD2_StreamTest.java (수업 실습 원본)
package hk.edu20260825.day16;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class D2_StreamTest {
public static void main(String[] args) {
// asList() -> 값을 한꺼번에 정의할때 편리함(단, 길이는 고정됨)
List<String> list = Arrays.asList("김태경", "김상원", "임OO"); // 세 번째는 실명을 가린 이름
Stream<String> streamList = list.stream();
streamList.filter(s -> s.contains("김")).sorted()
.forEach(s -> System.out.println(s));
// Stream객체는 한번 사용하면 끝남 -> 다시 못씀(파이프 개념이라 한번 소비하면 종료됨)
// 그래서 list를 계속 사용하는 경우라면 Stream객체를 바로 생성해서 사용
list.stream().filter(s -> s.startsWith("임")).sorted()
.forEach(System.out::println); // 메서드 참조: 값을 그대로 넘기기만 한다면 ::
// 람다식을 사용하지 않고 구현한다면?
List<String> list2 = new ArrayList<>();
for (String s : list) {
if (s.contains("김")) list2.add(s);
}
Collections.sort(list2);
for (String s : list2) System.out.println(s);
// map(): 값을 편집해서 새로운 스트림으로 반환(원본은 그대로)
List<Integer> listNum = list.stream()
.map(String::length)
.collect(Collectors.toList());
System.out.println(listNum.toString());
// List.of(): 불변객체(길이변경X), add()·set()·remove() 사용X
List<String> list3 = List.of("A", "B", "C", "D");
// 일반 스트림 -> main 스레드에서 순서대로 처리
list3.stream().forEach(s -> {
System.out.println(s + "-" + Thread.currentThread().getName());
});
System.out.println("\n========================\n");
// 병렬 스트림(Multi-Threaded Processing) -> 여러 워커 스레드로 나뉘어 처리(순서 보장 X)
list3.parallelStream().forEach(s -> {
System.out.println(s + "-" + Thread.currentThread().getName());
});
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인(병렬 스트림 순서는 실행마다 달라질 수 있습니다)
첫 줄은 "김"이 포함된 이름을 정렬해서 김상원 → 김태경 순으로, 둘째 묶음은 "임"으로 시작하는 이름(임OO 하나)을 찾아 출력하고, 셋째 묶음은 람다 없이 for문으로 같은 일을 해 다시 김상원 → 김태경이 나옵니다. listNum은 세 이름이 모두 3글자라 [3, 3, 3]입니다. 일반 스트림은 A·B·C·D가 전부 "main" 스레드에서 순서대로 나오지만, 병렬 스트림은 C·D는 main, A·B는 ForkJoinPool의 다른 워커 스레드에서 처리되어 순서가 뒤섞인 채 출력됩니다 — 실행할 때마다 이 순서는 달라질 수 있습니다.
package hk.edu20260825.day16;
import java.io.*;
public class D3_IOTest {
public static void main(String[] args) {
// test01(); test02(); test02_2(); test03(); test04();
test05();
}
// try-with-resources 문법 사용으로 finally 생략가능
private static void test01() {
try (
InputStream in = new FileInputStream(".../temp/test.txt");
OutputStream out = new FileOutputStream(".../temp/test2.txt");) {
int i = 0;
while ((i = in.read()) != -1) {
System.out.println(i);
out.write(i);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
// filter: 보조스트림을 이용해서 출력하기
private static void test02() {
String s = "파일을 기록합니다.";
String ss = "파일을 문자단위로 기록합니다.";
try (
// data 출력하는 파이프 생성
OutputStream out = new FileOutputStream(".../temp/test3.txt");
OutputStreamWriter ow = new OutputStreamWriter(out, "utf-8");
BufferedWriter bw = new BufferedWriter(ow);) {
// 1단계: 스트림만 사용할 경우
// out.write(s.getBytes()); // 문자열을 byte단위로 쪼개서 처리함
// 2단계 :writer를 사용할 경우
// -file바이트 출력 스트림 -> 문자기반 출력 필터를 끼우고 실행(알아서 쪼개서 처리함)
// ow.write(ss);
// 3단계: 버퍼를 사용할 경우
// 성능향상: 문자하나마다 출력 처리 -> 많은 양의 문자를 모아서 한번에 출력(기본 8kb씩 저장해서 출력)
bw.write(ss);
bw.newLine(); // 줄바꿈
} catch (IOException e) {
e.printStackTrace();
}
}
// filter: 보조스트림사용
public static void test02_2() {
String s = "파일을 기록합니다.";
try (
OutputStream out = new FileOutputStream(".../temp/test4.txt");
// filter: 기본 데이터 타입을 이진데이터로 출력(자바프로그램끼리 주고받고 처리할때 사용)
DataOutputStream ds = new DataOutputStream(out);) {
// ds.writeUTF(s);
ds.write(s.getBytes());
} catch (IOException e) {
e.printStackTrace();
}
}
// 한번에 읽을 때 크기를 설정해서 읽고 쓰기(이미지 파일 복사)
private static void test03() {
try (
InputStream in = new FileInputStream(".../temp/images.jpg");
OutputStream out = new FileOutputStream(".../temp/copy.jpg");) {
byte[] b = new byte[10]; // 10바이트 단위로 읽기
int i = 0;
while ((i = in.read(b)) != -1) { // i에 저장되는 값은 읽은 개수
// out.write(b); // 그전에 읽었던 배열의 데이터가 남아있을수있음
out.write(b, 0, i); // 읽은 개수만큼만 출력 -> 안정적(권장)
}
} catch (IOException e) {
e.printStackTrace();
}
}
// 과제: 다이어리 구현하기 — 한 줄 입력하고 엔터 --> 메모장에 이어서 출력, "exit" 입력 시 종료
private static void test05() {
try (
InputStreamReader in = new InputStreamReader(System.in, "MS949");
BufferedReader br = new BufferedReader(in);
// true: 기존 내용 뒤에 이어쓰기
FileOutputStream out = new FileOutputStream(".../temp/test5.txt", true);
OutputStreamWriter ow = new OutputStreamWriter(out, "utf-8");
BufferedWriter bw = new BufferedWriter(ow);) {
System.out.println("입력시작. (exit 입력 시 종료)");
String msg = "";
while ((msg = br.readLine()) != null) {
if (msg.equals("exit")) {
System.out.println("종료합니다.");
break;
}
bw.write(msg);
bw.newLine();
bw.flush(); // 버퍼 비우기(파일에 즉시 기록)
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
실행 결과test05 다이어리에 "오늘은 람다와 스트림을 배웠다" → "다음엔 스레드를 배운다" → "exit" 순으로 입력 먼저 예측 → 펼쳐서 확인
입력시작. (exit 입력 시 종료)
종료합니다.
--- test5.txt (실행 전) ---
안녕하세요
테스트 입력
입니다.
--- test5.txt (실행 후) ---
안녕하세요
테스트 입력
입니다.
오늘은 람다와 스트림을 배웠다
다음엔 스레드를 배운다
위 출력은 입력값을 자동으로 넣어 주며 실행한 결과라 입력한 줄은 생략했고, 프로그램이 직접 찍는 "입력시작"과 "종료합니다"만 남겼습니다(Eclipse 콘솔에서 직접 치면 입력한 내용도 화면에 보입니다). 진짜 확인할 곳은 test5.txt — 실행 전 내용은 그대로 남아 있고 새로 입력한 두 줄만 뒤에 추가됐습니다. true 옵션(이어쓰기 모드) 없이 FileOutputStream을 열었다면 이전 내용이 전부 지워지고 새 내용으로 덮어써졌을 것입니다.
JAVAD4_ThreadTest.java (수업 실습 원본)
package hk.edu20260825.day16;
public class D4_ThreadTest {
public static void main(String[] args) {
// "안", "녕" 번갈아 가며 출력하기(순차 실행)
for (int i = 0; i < 5; i++) {
System.out.print("안");
}
for (int i = 0; i < 5; i++) {
System.out.print("녕");
}
System.out.println();
System.out.println("===============");
// 작업단위1
Thread t1 = new Thread() { // 익명클래스 방식으로...
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.print("안");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
// 작업단위2
Thread t2 = new Thread() {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.print("녕");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
// Thread의 run()을 실행시켜주는 메서드: start() --> 실행대기 상태로 보냄
// 주의: .run()을 직접 호출하면 쓰레드가 생성되지 않는다!
t1.start();
t2.start();
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인(스레드 인터리빙 순서는 실행마다 달라질 수 있습니다)
안안안안안녕녕녕녕녕
===============
안녕녕안녕안녕안녕안
첫 줄은 순차 실행이라 "안"이 다 끝난 뒤에야 "녕"이 시작됩니다. 구분선 아래는 t1·t2를 start()한 결과인데, "안"과 "녕"이 뒤섞여 찍힙니다 — 두 스레드가 동시에 진행되면서 각자 0.5초씩 쉬는 사이 다른 스레드가 끼어들 틈이 생기기 때문입니다. 이 순서는 OS 스케줄러가 정하는 것이라 실행할 때마다 조금씩 달라질 수 있습니다.
16일차에 배운 것 — 핵심 정리
람다식은 추상 메서드가 하나뿐인 인터페이스(함수형 인터페이스)에서만 쓸 수 있다.
(매개변수) -> 실행문 형태이며, 실행문이 return 한 줄이면 중괄호·return을 생략할 수 있다.
Stream은 중간 연산(filter·sorted·map) + 최종 연산(forEach·collect)으로 구성되고, 한 번 소비하면 재사용할 수 없다.
map()은 원본을 바꾸지 않고 새로운 값들의 스트림을 반환한다.
람다식이 값을 그대로 다른 메서드에 넘기기만 한다면 메서드 참조(클래스::메서드)로 줄일 수 있다.
parallelStream()은 여러 스레드로 나눠 처리해 빠를 수 있지만 순서를 보장하지 않는다.
InputStream.read()는 1바이트씩만 읽으며, 더 읽을 게 없으면 -1을 반환한다 — 그래서 while로 반복해야 한다.
자원은 열었던 순서의 반대(또는 나중까지 쓰는 것부터)로 닫는 것이 안전하다 — try-with-resources를 쓰면 이 처리가 자동화된다.
보조 스트림(Writer·Buffered·Data)은 원시 스트림을 감싸서 기능을 추가한다(문자 변환, 성능 향상, 이진 데이터 처리).
한 번에 여러 바이트를 읽을 땐(read(byte[])) 실제로 읽은 개수만큼만 써야 한다(write(b, 0, i)) — 배열 전체를 쓰면 이전 데이터가 섞일 수 있다.
FileOutputStream(경로, true)의 true는 이어쓰기 모드 — 생략하면(기본값 false) 기존 내용을 덮어쓴다.
스레드는 Thread를 상속하거나 Runnable을 구현해서 만들고, run()이 아니라 반드시 start()를 호출해야 새 스레드가 생긴다.
스레드를 start()하면 여러 작업이 동시에 진행된다(멀티코어면 실제 동시 실행, 아니면 빠르게 번갈아 실행 = 시분할). 출력이 섞이는 순서(인터리빙)는 실행할 때마다 달라질 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D4_ThreadTest에서 t1.start(); t2.start(); 를 t1.run(); t2.run(); 으로 바꾸면 출력은?
구분선 아래도 "안안안안안녕녕녕녕녕"으로 순서대로 찍힙니다. run()을 직접 부르면 새 스레드가 생기지 않고 main 스레드가 평범한 메서드를 호출하는 것이라, t1.run()이 2.5초 동안 다 끝난 뒤에야 t2.run()이 시작되기 때문입니다. 새 실행 흐름을 만들어 그 안에서 run()을 돌려 주는 것은 start()뿐입니다.
D2_StreamTest에서 이미 forEach까지 끝난 streamList로 streamList.filter(...)를 한 번 더 부르면?
컴파일은 되지만 실행하면 IllegalStateException("stream has already been operated upon or closed")이 납니다. Stream은 최종 연산을 한 번 하면 소비되어 끝나는 파이프라 재사용할 수 없습니다. 그래서 수업 코드처럼 필요할 때마다 list.stream()으로 새 스트림을 만들어 씁니다 — 원본 list는 그대로라 몇 번이든 다시 만들 수 있습니다.
test03에서 out.write(b, 0, i) 대신 out.write(b)를 쓰면 복사된 이미지는 어떻게 될 수 있을까?
원본보다 최대 9바이트 커지고 끝부분이 깨질 수 있습니다. 파일 끝에서 마지막 read(b)는 10바이트를 다 못 채우고(예: 3바이트) i에 3을 돌려주는데, write(b)는 배열 10칸을 전부 쓰므로 나머지 7칸에 남아 있던 직전 데이터까지 파일에 붙습니다. read가 돌려준 개수만큼만 쓰는 것이 원칙이고, 19일차 UDP 버그도 같은 이유입니다.
💼 실무·코딩테스트에서는실무 서비스 코드에서는 DB에서 가져온 List<Dto>를 stream().filter().map().collect()로 걸러 내고 변환하는 코드를 매일 보게 되고, 파일 업로드·다운로드는 오늘처럼 버퍼 스트림 + try-with-resources 조합이 기본입니다. parallelStream()은 웹 서버 안에서는 공용 스레드 풀을 나눠 쓰기 때문에 함부로 쓰지 않습니다. 코딩테스트에서는 정렬·변환을 스트림이나 Arrays.sort로 짧게 쓰는 일이 많은데(정수 내림차순으로 배치하기 — 스트림이면 sorted(Comparator.reverseOrder())), 시간이 빠듯하면 for문이 더 빠를 수 있다는 것도 기억해 두세요. 면접에서는 "start()와 run()의 차이", "Stream의 중간 연산과 최종 연산", "함수형 인터페이스란"을 자주 묻습니다.
TIPD2_StreamTest의 Arrays.asList(...)로 만든 list는 크기가 고정이라 add()·remove()는 못 쓰지만 set()은 됩니다(배열을 감싼 것이라 그렇습니다) — List.of(...)로 만든 list3은 그보다 더 엄격한 완전 불변이라 set()조차 안 됩니다. D3_IOTest.test04·test05가 InputStreamReader(System.in, "MS949")로 인코딩을 명시하는 이유도 기억해 두세요 — 운영체제 콘솔의 기본 인코딩과 맞춰야 한글이 안 깨집니다(리눅스 서버 콘솔은 보통 UTF-8이라 상황에 따라 인코딩을 바꿔야 할 수도 있습니다). 그리고 D4_ThreadTest의 Thread.sleep(500)은 일부러 넣은 지연입니다 — 없으면 한 스레드가 너무 빨리 끝나 버려서 인터리빙이 잘 안 보일 수 있습니다.
한 줄 요약16일차 스레드 맛보기에 이어 Runnable 구현과 Thread 상속 두 가지 생성 방식을 비교하고 우선순위를 지정해 봤고, 여러 스레드가 같은 객체를 동시에 건드릴 때 생기는 문제를 synchronized로 막는 법과 wait()/notifyAll()로 빵이 0개면 먹는 사람이, 10개면 만드는 사람이 기다리게 하는 생산자-소비자 패턴을 실습했으며, 마지막엔 Socket·ServerSocket으로 가장 기본적인 TCP 클라이언트-서버 대화를 완성해 자바 파트의 마지막 주제(네트워크)에 들어섰다. (17일차(8/26) 수업 자료는 없어서 16일차 다음 카드가 바로 18일차입니다.)
쉽게 말하면오늘은 여러 사람이 같이 일할 때 생기는 문제와 그 해결책을 배운 날입니다. synchronized는 "이 냄비 쓰는 동안 다른 사람은 손대지 마"라고 잠그는 것이고, 생산자-소비자 패턴은 빵집 진열대와 같아요 — 진열대가 꽉 차면 만드는 사람이 잠깐 멈추고(wait), 손님이 하나 사 가면 다시 만들기 시작합니다(notifyAll). TCP 소켓은 두 프로그램 사이에 놓인 전화선이고, 서버는 전화를 받을 준비를 하고 기다리는 쪽, 클라이언트는 그 번호로 거는 쪽입니다.
① Runnable 구현 vs Thread 상속 — 두 가지 생성법과 우선순위
D1_RunableTest implements Runnable은 run()만 구현한 뒤 new Thread(runObj)로 작업 내용과 스레드를 분리해서 씁니다. D1_ThreadInheriTest extends Thread는 Thread 자체를 상속해 new D1_ThreadInheriTest()로 바로 스레드가 됩니다("다른 클래스도 상속해야 한다면 Runnable을 써야 한다"는 이유가 여기서 실감 납니다 — 자바는 단일 상속이라 Thread를 상속하면 다른 클래스를 상속 못 하지만, 인터페이스인 Runnable은 상속과 함께 구현할 수 있으니까요). tr1.setPriority(Thread.MAX_PRIORITY)·tr2.setPriority(Thread.MIN_PRIORITY)로 우선순위(1~10)를 다르게 줬는데, 실제 실행 결과를 보면 우선순위가 높다고 항상 먼저 끝나는 건 아니고 "더 자주 스케줄링될 가능성이 높다" 정도로 이해하는 게 정확합니다 — OS 스케줄러가 최종 결정을 하기 때문입니다. → 🛠️ 11. 스레드(Thread)
② D2_ThreadSync — 공유 객체를 여러 스레드가 건드릴 때
정적 필드 StringBuffer sf를 tr1·tr2 두 스레드가 동시에sbTest("A")·sbTest("B")로 각각 1000번씩 append합니다. 결과는 두 스레드 모두 정확히 2000을 출력했는데, 이건 StringBuffer가 메서드 안에 이미 synchronized가 걸려 있어 스레드에 안전하기 때문입니다(🧱 15. StringBuilder 카드에서 정리한 "StringBuilder는 빠르지만 동기화가 없고, StringBuffer는 느리지만 안전하다"는 차이가 실전에서 확인된 셈입니다). 코드 위쪽의 trA·trB가 ShareObject.print()를 감싸는 synchronized(so) { ... } 블록은 "이 객체를 쓰는 동안 다른 스레드는 못 들어온다"를 명시적으로 거는 방법입니다(단 start()가 주석 처리돼 실제로 실행되지는 않음 — 아래 TIP). → 🧱 15. StringBuilder
③ D3_Cake — wait()/notifyAll()로 빵 개수를 0~10 안에 묶어 두기
D3_CakePlate는 breadCount 하나를 두 스레드(D3_CakeMaker·D3_CakeEater)가 공유합니다. makeBread()는 10개가 되면wait()으로 자기 자신을 잠재우고, eatBread()는 0개가 되면 마찬가지로 wait()합니다. 둘 다 작업을 하나 마칠 때마다 notifyAll()로 기다리고 있던 다른 스레드를 깨웁니다. 두 메서드 모두 synchronized인 이유는 wait()/notifyAll()이 반드시 동기화된 영역 안에서만 호출될 수 있기 때문입니다. wait()/notifyAll()이 보장하는 건 "빵 개수가 0 아래로 내려가거나 10을 넘지 않는다"는 것뿐입니다. "10개 다 만들고 → 10개 다 먹고"처럼 정확히 번갈아 돈다는 보장은 없습니다 — notifyAll()은 하나 만들 때마다 상대를 깨우므로, 3개쯤 만들다 먹고 다시 만드는 식으로 섞이는 순서는 실행마다 달라집니다. 대표적인 생산자-소비자(Producer-Consumer) 패턴입니다. 그리고 실무에서는 if (조건) wait();가 아니라 while (조건) wait();로 씁니다 — 깨어난 뒤 조건을 다시 확인해야 다른 스레드가 먼저 값을 바꿔 놓았거나 이유 없이 깨어난 경우(spurious wakeup)에도 안전하기 때문입니다.
④ D4_TCP — 서버는 기다리고, 클라이언트는 건다
ServerSocket(9595)로 9595번 포트에 문을 열어 두고accept()에서 클라이언트가 올 때까지 멈춰서 기다립니다(블로킹). 클라이언트가 new Socket("localhost", 9595)로 접속하면 accept()가 그 연결을 담은 Socket을 반환하고, 이제 서버·클라이언트 둘 다 PrintWriter(보내기)와 BufferedReader(받기)로 같은 소켓을 통해 문장을 주고받습니다. 서버의 while ((inputLine = in.readLine()) != null)은 클라이언트가 연결을 끊기 전까지 계속 메시지를 받는 구조입니다. 다만 이 서버는 accept()가 한 번에 클라이언트 하나만 처리할 수 있는 한계가 있는데, 이걸 스레드로 해결하는 게 19일차입니다. → 🌐 06. TCP/IP 소켓 프로그래밍
package hk.edu20260827.day18;
public class D1_RunableTest implements Runnable {
@Override
public void run() {
for (int i = 0; i < 50; i++) {
System.out.println("나는 Runnable을 구현한 스레드다");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class D1_ThreadInheriTest extends Thread {
@Override
public void run() {
for (int i = 0; i < 50; i++) {
System.out.println("나는 Thread를 상속받은 스레드다");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class D1_ThreadMain {
public static void main(String[] args) {
// 1. Runnable을 구현
Runnable runObj = new D1_RunableTest();
Thread tr1 = new Thread(runObj);
tr1.start();
tr1.setPriority(Thread.MAX_PRIORITY); // 우선순위범위: 1~10까지
// 2. Thread 클래스 상속
Thread tr2 = new D1_ThreadInheriTest();
tr2.start();
tr2.setPriority(Thread.MIN_PRIORITY); // 가장 하위순위 설정
// 메인스레드
for (int i = 0; i < 5; i++) {
System.out.println("나는 메인 스레드다");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
실행 결과앞부분만 발췌(main 5회, 두 스레드 각 50회 반복이라 전체는 100줄 이상) 먼저 예측 → 펼쳐서 확인
나는 메인 스레드다
나는 Runnable을 구현한 스레드다
나는 Thread를 상속받은 스레드다
나는 Thread를 상속받은 스레드다
나는 메인 스레드다
나는 Runnable을 구현한 스레드다
나는 메인 스레드다
나는 Runnable을 구현한 스레드다
나는 Thread를 상속받은 스레드다
...(이하 반복, 세 스레드가 뒤섞여 출력됨)
메인 스레드는 5번만 찍고 먼저 끝나지만, tr1·tr2는 각각 50번씩 계속 찍습니다. 어느 줄이 먼저 나올지는 우선순위와 상관없이 실행마다 달라질 수 있습니다 — setPriority는 "힌트"일 뿐 확정된 순서를 보장하지 않습니다.
JAVAD2_ThreadSync.java (수업 실습 원본 — synchronized 블록 예시(실행은 주석 처리) + sbTest)
package hk.edu20260827.day18;
public class D2_ThreadSync {
public static StringBuilder sb = new StringBuilder();
public static StringBuffer sf = new StringBuffer();
public void sbTest(String s) {
for (int i = 0; i < 1000; i++) {
sf.append(s);
}
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(sf.length()); // 1000인지 확인
}
public static void main(String[] args) {
// 공유객체 생성
ShareObject so = new D2_ThreadSync().new ShareObject();
// A와 B 스레드가 동시적으로 하나의 객체에 접근하는 상황
// 동기화 설정하기: A스레드가 작업중이고 B스레드가 접근하려고 하면
// A가 작업을 마칠때까지 기다려야한다.
// 설정하는 방법 2가지: synchronized메서드, synchronized블럭
Thread trA = new Thread() {
@Override
public void run() {
synchronized (so) {
so.print("공");
}
}
};
Thread trB = new Thread() {
@Override
public void run() {
synchronized (so) {
so.print("유");
}
}
};
// trA.start(); // ← 주석 처리되어 있어 실제로는 실행되지 않음
// trB.start();
// ======================
// 스레드 2개를 위에 작성한것처럼 정의해서
// sbTest() 실행해보기
D2_ThreadSync d2 = new D2_ThreadSync();
Thread tr1 = new Thread() {
@Override
public void run() {
d2.sbTest("A"); // A를 1,000번 추가
}
};
Thread tr2 = new Thread() {
@Override
public void run() {
d2.sbTest("B"); // B를 1,000번 추가
}
};
tr1.start();
tr2.start();
}
// 내부클래스
class ShareObject {
// public synchronized void print(String title)
// 동기화 할 영역에 synchronized를 걸어주면 됨(객체에 걸어주면 그 객체에 접근한 애들만 못들어감)
public void print(String title) {
for (int i = 0; i < 10; i++) {
System.out.println(title);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
실행 결과실제로 돌려 본 출력 먼저 예측 → 펼쳐서 확인
2000
2000
tr1·tr2가 같은 정적 필드 sf에 각자 1000번씩 append했으니 최종 길이는 2000이어야 하는데, 두 스레드 모두 정확히 2000을 출력합니다 — StringBuffer의 내부 메서드들이 이미 synchronized라서 두 스레드가 동시에 써도 데이터가 깨지지 않습니다. 만약 StringBuilder(동기화 없음)로 같은 실험을 했다면 두 스레드의 쓰기가 겹치면서 2000보다 작은 값이 나올 수 있습니다.
package hk.edu20260827.day18;
public class D3_CakePlate {
private int breadCount = 0;
// eatBread()가 10개 모두 소진(notifyAll) -> wait()
// -> makeBread()가 실행 10개 모두 만듦(notifyAll) -> wait()
public synchronized void eatBread() {
if (breadCount < 1) {
System.out.println("빵이 모자라서 기다려야 함");
try {
wait(); // 스레드를 일시정지
} catch (InterruptedException e) {
e.printStackTrace();
}
}
breadCount--;
System.out.println("빵을 1개 먹음. 총 " + breadCount + "개 남음");
notifyAll(); // 모든 스레드를 실행대기로 설정
}
public synchronized void makeBread() {
if (breadCount >= 10) {
System.out.println("빵이 남아요!");
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
breadCount++;
System.out.println("빵을 1개 더 만듦. 총 " + breadCount + "개");
notifyAll();
}
}
public class D3_CakeEater implements Runnable {
private D3_CakePlate cake;
public D3_CakeEater(D3_CakePlate cake) { this.cake = cake; }
@Override
public void run() {
for (int i = 0; i < 30; i++) cake.eatBread();
}
}
public class D3_CakeMaker implements Runnable {
private D3_CakePlate cake;
public D3_CakeMaker(D3_CakePlate cake) { this.cake = cake; }
@Override
public void run() {
for (int i = 0; i < 30; i++) cake.makeBread();
}
}
public class D3_CakeMain {
public static void main(String[] args) {
D3_CakePlate cake = new D3_CakePlate(); // 스레드들이 공유할 객체
D3_CakeEater eater = new D3_CakeEater(cake);
D3_CakeMaker maker = new D3_CakeMaker(cake);
Thread t1 = new Thread(eater);
Thread t2 = new Thread(maker);
t2.setPriority(10);
t1.setPriority(1);
t1.start();
t2.start();
}
}
실행 결과앞부분만 발췌(30개씩 총 60번 오가는 전체 로그는 훨씬 깁니다) 먼저 예측 → 펼쳐서 확인
빵이 모자라서 기다려야 함
빵을 1개 더 만듦. 총 1개
빵을 1개 더 만듦. 총 2개
...(총 10개까지)
빵을 1개 더 만듦. 총 10개
빵이 남아요!
빵을 1개 먹음. 총 9개 남음
...(총 0개까지)
빵을 1개 먹음. 총 0개 남음
빵이 모자라서 기다려야 함
빵을 1개 더 만듦. 총 1개
...(이 패턴이 반복됨)
먹는 스레드가 먼저 시작돼 breadCount가 0이라 바로 wait()로 잠듭니다. 이번 실행에서는 만드는 스레드가 10개를 채운 뒤 "빵이 남아요!"로 멈추고, 먹는 스레드가 0개까지 먹은 뒤 다시 잠드는 모양으로 나왔습니다. 하지만 이건 한 번 실행한 예시일 뿐입니다 — wait()/notifyAll()이 보장하는 건 빵 개수가 항상 0~10 사이라는 것뿐이고, 몇 개씩 만들고 먹으며 섞이는지는 스케줄러에 따라 실행마다 달라집니다(예: 4개 만들고 2개 먹고 다시 만들기).
package hk.edu20260827.day18;
public class D4_TCPServer {
public static void main(String[] args) {
try (
var serverSocket = new java.net.ServerSocket(9595);) {
System.out.println("서버가 클라이언트의 접속을 기다립니다...");
while (true) {
var clientSocket = serverSocket.accept(); // 접속할 때까지 대기(블로킹)
System.out.println("클라이언트 연결됨:" + clientSocket.getInetAddress().getHostName());
var out = new java.io.PrintWriter(clientSocket.getOutputStream(), true);
var in = new java.io.BufferedReader(
new java.io.InputStreamReader(clientSocket.getInputStream()));
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("클라이언트 메시지:" + inputLine);
out.println("메시지를 잘 전달받았습니다.");
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class D4_TCPClient {
public static void main(String[] args) {
try (
Socket socket = new Socket("localhost", 9595); // 요청 ip, port
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
BufferedReader userIn = new BufferedReader(new InputStreamReader(System.in, "MS949"))) {
System.out.println("Client:Connetion to server...");
String inputLine;
while ((inputLine = userIn.readLine()) != null) {
out.println(inputLine);
System.out.println("서버에서 전달된 메시지:" + in.readLine());
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
실행 결과서버를 먼저 띄우고, 클라이언트에서 "안녕하세요" → "좋은 하루 되세요" 입력 먼저 예측 → 펼쳐서 확인
[서버 콘솔]
서버가 클라이언트의 접속을 기다립니다...
클라이언트 연결됨:127.0.0.1
클라이언트 메시지:안녕하세요
클라이언트 메시지:좋은 하루 되세요
[클라이언트 콘솔]
Client:Connetion to server...
서버에서 전달된 메시지:메시지를 잘 전달받았습니다.
서버에서 전달된 메시지:메시지를 잘 전달받았습니다.
서버와 클라이언트를 각각 다른 콘솔에서 동시에 실행해야 하는 실습입니다(서버를 먼저 띄우고 기다리게 한 뒤 클라이언트를 실행). 클라이언트가 한 줄 보낼 때마다 서버가 받아 콘솔에 찍고, 곧바로 "메시지를 잘 전달받았습니다."를 돌려보내는 왕복(요청-응답) 구조가 그대로 확인됩니다.
18일차에 배운 것 — 핵심 정리
Runnable 구현은 작업과 스레드를 분리(다른 클래스 상속 중이어도 사용 가능), Thread 상속은 간결하지만 다른 클래스를 상속 못 함.
setPriority(1~10)는 힌트일 뿐 — 실행 순서를 확정 짓지 않는다.
StringBuffer는 내부적으로 동기화되어 있어 여러 스레드가 동시에 써도 안전하지만, StringBuilder는 그렇지 않다.
synchronized로 감싼 영역은 한 번에 스레드 하나만 들어갈 수 있다.
wait()/notifyAll()은 반드시 synchronized 블록/메서드 안에서만 호출할 수 있다.
생산자-소비자 패턴: 조건이 안 맞으면 wait()으로 잠들고, 작업이 끝나면 notifyAll()로 깨운다. 보장되는 건 개수 범위(0~10)뿐이고 번갈아 도는 순서는 매번 다르다. 조건 검사는 while (조건) wait();로 쓰는 것이 안전하다.
ServerSocket.accept()는 클라이언트가 접속할 때까지 그 자리에서 멈춰 기다린다(블로킹).
기본 TCP 서버는 accept()가 한 번에 하나씩만 처리 — 동시에 여러 클라이언트를 받으려면 스레드가 필요하다(19일차로 이어짐).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D3_CakePlate의 eatBread()에서 synchronized를 지우면 어떻게 될까?
컴파일은 되지만 실행 중 wait()나 notifyAll()을 부르는 순간 IllegalMonitorStateException이 납니다. 두 메서드는 그 객체의 잠금(모니터)을 쥐고 있는 스레드만 부를 수 있기 때문입니다. 게다가 잠금이 없으면 breadCount--와 breadCount++가 동시에 실행되어 개수가 틀어질 수도 있습니다.
먹는 스레드를 두 개로 늘리면, if (breadCount < 1) wait(); 코드에서 빵이 -1개가 될 수 있을까?
될 수 있습니다. 빵이 0개라 두 먹는 스레드가 모두 wait() 중일 때 만드는 스레드가 1개를 만들고 notifyAll()하면 둘 다 깨어납니다. 먼저 잠금을 얻은 쪽이 먹어서 0개가 되고, 다음 쪽은 if라 조건을 다시 보지 않고wait() 다음 줄로 내려가 breadCount--를 해서 -1이 됩니다. while (breadCount < 1) wait();로 쓰면 깨어날 때마다 다시 확인하므로 0~10 범위가 지켜집니다.
sbTest는 1000번만 append하는데 왜 두 스레드 모두 2000을 찍을까? sf를 StringBuilder로 바꾸면?
두 스레드가 같은 정적 필드 sf에 각자 1000번씩 붙이고, 길이를 찍기 전에 Thread.sleep(2000)으로 2초를 쉬기 때문에 그사이 양쪽 append가 모두 끝나 둘 다 2000을 봅니다. StringBuffer는 메서드가 동기화되어 있어 동시에 붙여도 글자를 잃지 않습니다. 동기화가 없는 StringBuilder로 바꾸면 쓰기가 겹치면서 2000보다 작은 값이 나오거나 드물게 예외가 날 수 있습니다(실행마다 다름).
💼 실무·코딩테스트에서는실무에서 wait()/notifyAll()을 직접 쓰는 일은 드물고, 같은 생산자-소비자 구조를 BlockingQueue로, 스레드 관리는 ExecutorService(스레드 풀)로, 공유 카운터·맵은 AtomicInteger·ConcurrentHashMap으로 처리합니다. 웹 서버(톰캣)는 요청마다 다른 스레드가 같은 서블릿·스프링 빈 객체를 쓰기 때문에, 그 객체의 필드에 요청별 값을 저장하면 오늘 본 동시성 문제가 그대로 생깁니다. 코딩테스트는 단일 스레드라 동기화가 오히려 손해입니다 — 예를 들어 크레인 인형뽑기 게임에서 쓰는 Stack은 Vector를 상속해 메서드가 synchronized라서, 요즘은 ArrayDeque를 권장합니다(StringBuffer 대신 StringBuilder를 쓰는 것과 같은 이유). 면접에서는 "StringBuilder와 StringBuffer의 차이", "synchronized와 데드락", "Runnable과 Thread 중 무엇을 쓰나"가 단골입니다.
TIPD2_ThreadSync의 ShareObject.print()를 실행하는 trA·trB 코드는 // trA.start(); // trB.start();로 주석 처리되어 있어 실제로는 실행되지 않습니다 — synchronized(so) { so.print(...) } 문법 자체를 보여주기 위한 예시로 남겨 둔 코드입니다. 실제로 돌아간 건 sbTest() 쪽입니다. 그리고 D3_CakeMain에서 t2.setPriority(10)(만드는 사람)·t1.setPriority(1)(먹는 사람)로 일부러 만드는 쪽 우선순위를 높였는데도, 우선순위와 무관하게 빵 개수가 10을 넘거나 0 아래로 내려가는 일은 없습니다 — wait()/notifyAll()이 강제하는 건 이 범위(0~10)이지 "10개 만들고 10개 먹고" 같은 정확한 순서가 아닙니다. 우선순위는 힌트일 뿐이고 동기화 로직이 훨씬 강한 규칙이라는 걸 보여주는 예입니다. 또 이 코드의 if (조건) wait();는 실무에선 while (조건) wait();로 바꿔 쓰세요 — 깨어난 뒤 조건을 다시 확인해야 안전합니다.
한 줄 요약18일차 TCP 서버의 "한 번에 클라이언트 하나만" 한계를 접속마다 스레드를 하나씩 만들어 풀었고, TCP와 대비되는 UDP(연결 없이 패킷 하나씩 주고받기)를 실습했으며, 마지막으로 두 가지를 합쳐 여러 명이 동시에 접속해 대화하는 채팅 서버·클라이언트를 완성해 자바 파트를 마무리했다(다음 날부터는 SQL).
쉽게 말하면18일차 서버가 손님을 한 명씩만 받는 창구 하나짜리 가게였다면, 오늘은 손님이 올 때마다 새 직원을 배정하는 가게로 바꿨습니다(멀티스레드 서버). UDP는 등기 없는 일반 우편이에요 — TCP처럼 "전화 연결"을 유지하지 않고 편지(패킷) 한 장을 그냥 던지는 방식이라 빠르지만 도착을 보장하지 않습니다. 채팅 서버는 오늘 배운 걸 다 합친 것 — 직원을 여러 명 두고(스레드), 한 사람이 말하면 전체 손님에게 방송(브로드캐스트)하는 가게입니다.
① D1_MultiTcpServer — 접속마다 스레드를 하나씩
18일차 서버는 accept() 한 번 받으면 그 클라이언트와 대화가 끝날 때까지 다음 손님을 못 받았습니다. 오늘은 while(true) 안에서 accept()를 받는 즉시 new ServerThread(clientSocket).start();로 그 손님 전담 스레드를 만들어 넘기고, 서버는 곧바로 다음 accept()로 돌아갑니다. 내부클래스 ServerThread extends Thread의 run()이 실제 대화(읽고 답장하기)를 처리합니다. try (Socket autoCloseSocket = clientSocket; ...)처럼 이미 만들어진 소켓을 괄호 안 변수에 등록만 해도 try-with-resources가 자동으로 닫아 준다는 것도 오늘의 새로운 문법입니다. → 🌐 08. 멀티스레드 서버와 채팅 프로그램 · 🛠️ 11. 스레드(Thread)
② UDP — 연결 없이 패킷 하나씩, 그래서 주소를 매번 적어야 한다
TCP는 accept()로 미리 연결을 맺어 두고 계속 주고받지만, UDP는 DatagramSocket·DatagramPacket으로 편지 봉투(패킷)마다 받는 사람 주소(InetAddress·포트)를 직접 적어 보냅니다 — 그래서 socket.receive(packet)으로 받은 뒤 packet.getAddress()·packet.getPort()로 "누가 보냈는지"를 매번 다시 확인해야 그 사람에게 답장할 수 있습니다(TCP처럼 연결이 유지되지 않으니까요). byte[] buffer = new byte[512];처럼 받을 크기를 미리 정해 둬야 하는 것도 TCP의 스트림 방식과 다른 점입니다. → 🌐 07. UDP 소켓 프로그래밍
③ D3_ChatServer — 접속자 명단을 동기화된 Set으로 관리하고 방송하기
Set<PrintWriter> clientWriters에 접속한 모든 클라이언트의 출력 스트림을 모아 두고, broadcast(message)가 그 명단을 순회하며 전체에게 같은 메시지를 뿌립니다. 명단이 Collections.synchronizedSet(new HashSet<>())인 이유는 여러 스레드(각 클라이언트 담당 스레드)가 동시에 명단을 추가·삭제하기 때문 — 18일차의 synchronized를 컬렉션 레벨에서 적용한 형태입니다. ClientHandler extends Thread는 손님 1명을 전담하는 "직원"으로, 맨 처음 받은 한 줄을 닉네임으로 삼고, 이후 들어오는 모든 줄을 broadcast("[닉네임]" + message)로 전체 방송합니다. finally에서 연결이 끊기면 명단에서 자신을 빼고 퇴장 메시지를 방송하는 정리까지 갖췄습니다. → 🌐 08. 멀티스레드 서버와 채팅 프로그램
④ D3_ChatClient — 보내는 스레드와 받는 스레드를 분리하기
채팅은 "내가 언제든 메시지를 보낼 수 있어야 하고, 동시에 남이 보낸 메시지도 언제든 받아야" 하는 양방향 통신입니다. 그래서 클라이언트는 메인 스레드는 계속 키보드 입력을 읽어 서버로 보내기만 하고, 별도의 Thread receiveThread = new Thread(() -> { while ((serverMessage = in.readLine()) != null) {...} });(람다식으로 만든 익명 스레드, 16일차 복습)가 서버에서 오는 메시지를 계속 받아서 화면에 출력합니다. 이 둘을 하나의 스레드로 합쳤다면 readLine()이 입력을 기다리는 동안 받은 메시지를 화면에 못 찍는 문제가 생겼을 것입니다 — "보내기"와 "받기"를 스레드로 분리하는 게 왜 필요한지 정확히 보여주는 예입니다.
JAVAD1_MultiTcpServer.java (수업 실습 원본)
package hk.edu20260828.day19;
public class D1_MultiTcpServer {
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(9595);) {
System.out.println("server is running now...");
while (true) {
Socket clientSocket = serverSocket.accept();
System.out.println("클라이언트 연결됨:" + clientSocket.getInetAddress().getHostName());
new ServerThread(clientSocket).start(); // 접속마다 새 스레드
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 내부클래스로 스레드 클래스 작성(static으로 정의)
static class ServerThread extends Thread {
Socket clientSocket = null;
public ServerThread(Socket clientSocket) {
this.clientSocket = clientSocket;
}
@Override
public void run() {
try (
// close() 생략하려면 괄호에 정의되어야 한다.
// autoCloseSocket은 임의의 변수에다 등록만 해두면 됨
Socket autoCloseSocket = clientSocket;
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("클라이언트로부터 전달받은 메시지:" + inputLine);
out.println("보낸 메시지:" + inputLine);
}
} catch (IOException e) {
}
}
}
}
실행 결과서버 1개 + 클라이언트 2개(A·B)를 동시에 접속시켜 본 결과 먼저 예측 → 펼쳐서 확인
[서버 콘솔]
server is running now...
클라이언트 연결됨:127.0.0.1
클라이언트로부터 전달받은 메시지:A입니다
클라이언트 연결됨:127.0.0.1
클라이언트로부터 전달받은 메시지:B입니다
[클라이언트 A 콘솔]
Client:Connetion to server...
서버에서 전달된 메시지:보낸 메시지:A입니다
[클라이언트 B 콘솔]
Client:Connetion to server...
서버에서 전달된 메시지:보낸 메시지:B입니다
클라이언트는 18일차 D4_TCPClient(포트 9595)를 그대로 썼습니다. 이 클라이언트는 System.out.println("서버에서 전달된 메시지:" + in.readLine());한 줄로 출력하므로, 서버가 돌려준 "보낸 메시지:A입니다"가 같은 줄 뒤에 붙어 나옵니다. 두 클라이언트가 동시에 접속해도 서버는 각각을 독립된 ServerThread로 처리해서 서로 간섭 없이 자기 메시지를 돌려받습니다. 18일차 서버였다면 A가 연결을 안 끊는 한 B는 accept()조차 못 받고 대기해야 했을 상황입니다.
package hk.edu20260828.day19;
public class D2_UDPServer {
public static void main(String[] args) {
try (DatagramSocket socket = new DatagramSocket(5000);) {
byte[] buffer = new byte[512];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length); // 수신용 패킷
System.out.println("server is listening on port 5000");
while (true) {
socket.receive(packet); // 클라이언트에서 전송된 데이터를 패킷으로 받자
String received = new String(packet.getData(), 0, packet.getLength());
System.out.println("받은 메시지:" + received);
InetAddress address = packet.getAddress(); // 보낸 사람 주소를 매번 다시 확인
int port = packet.getPort();
System.out.println("address:" + address + ",port:" + port);
packet = new DatagramPacket(buffer, buffer.length, address, port);
socket.send(packet); // 클라이언트로 그대로 돌려보냄(에코)
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class D2_UDPClient {
public static void main(String[] args) {
String hostname = "192.168.22.2"; // 실습 환경의 서버 IP(로컬 테스트는 localhost)
int port = 5000;
try (DatagramSocket socket = new DatagramSocket();
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, "MS949"));) {
InetAddress address = InetAddress.getByName(hostname);
byte[] receiveBuffer = new byte[512];
String text = "";
while (true) {
System.out.println("입력하기:");
text = reader.readLine();
byte[] sendBuffer = text.getBytes();
DatagramPacket packet = new DatagramPacket(sendBuffer, sendBuffer.length, address, port);
socket.send(packet); // 서버로 전송
packet = new DatagramPacket(receiveBuffer, receiveBuffer.length);
socket.receive(packet); // 서버에서 수신
String received = new String(packet.getData(), 0, packet.getLength());
System.out.println("받은 메시지:" + received);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
실행 결과"핑퐁 테스트1" → "핑퐁 테스트2" 순으로 보내 본 결과 먼저 예측 → 펼쳐서 확인
[서버 콘솔]
server is listening on port 5000
받은 메시지:핑퐁 테스트1
address:/127.0.0.1,port:62320
받은 메시지:핑퐁 테스트2
address:/127.0.0.1,port:62320
[클라이언트 콘솔]
받은 메시지:핑퐁 테스트1(뒤에 공백처럼 보이는 여분 데이터가 붙어서 돌아옴)
받은 메시지:핑퐁 테스트2(마찬가지)
서버가 받은 메시지는 정확했지만, 돌려보낼 때 문제가 있었습니다 — packet = new DatagramPacket(buffer, buffer.length, address, port);에서 buffer.length(항상 512)를 그대로 길이로 써서, 실제로 받은 글자 수(packet.getLength())가 아니라 buffer 배열 512바이트 전체를 돌려보냅니다 — 짧은 메시지 뒤에 이전에 담겨 있던 찌꺼기 바이트까지 같이 전송되는 버그입니다. new DatagramPacket(buffer, packet.getLength(), address, port)로 고쳐야 정확히 받은 만큼만 돌려보낼 수 있습니다. 16일차D3_IOTest.test03에서 배운 "read(b)는 실제로 읽은 개수만큼만 써야 한다"는 교훈이 여기서도 그대로 적용되는 사례입니다.
JAVAD3_ChatServer.java (수업 실습 원본 — 핵심 부분)
package hk.edu20260828.day19;
public class D3_ChatServer {
// 채팅방에 접속한 모든 클라이언트의 출력 스트림을 모아두는 명단
// 여러 스레드가 동시에 추가/삭제하므로 동기화(synchronizedSet) 필수!
private static Set<PrintWriter> clientWriters = Collections.synchronizedSet(new HashSet<>());
public static void main(String[] args) {
int port = 12345;
try (ServerSocket serverSocket = new ServerSocket(port)) {
System.out.println("채팅 서버가 " + port + "번 포트에서 시작됨");
while (true) {
Socket clientSocket = serverSocket.accept(); // 손님이 올 때까지 대기
new ClientHandler(clientSocket).start(); // 손님마다 전담 직원(스레드) 배정
}
} catch (IOException e) {
e.printStackTrace();
}
}
// 접속 중인 모든 클라이언트에게 메시지를 전달하는 방송(브로드캐스트) 메서드
public static void broadcast(String message) {
synchronized (clientWriters) {
for (PrintWriter writer : clientWriters) {
writer.println(message);
}
}
}
// 손님 1명을 전담해서 처리할 스레드 클래스
static class ClientHandler extends Thread {
private Socket socket;
private PrintWriter out;
private BufferedReader in;
private String nickname;
public ClientHandler(Socket socket) { this.socket = socket; }
@Override
public void run() {
try {
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
out = new PrintWriter(socket.getOutputStream(), true);
clientWriters.add(out); // 전체 명단에 내 마이크 등록
nickname = in.readLine(); // 맨 처음 받은 한 줄은 닉네임으로 사용
broadcast(nickname + "님이 입장하셨습니다.");
String message;
while ((message = in.readLine()) != null) {
broadcast("[" + nickname + "]" + message);
}
} catch (IOException e) {
System.out.println(nickname + "님 연결 종료/오류: " + e.getMessage());
} finally {
if (out != null) clientWriters.remove(out); // 명단에서 제거
if (nickname != null) broadcast(nickname + "님이 나가셨습니다.");
try { socket.close(); } catch (IOException e) { e.printStackTrace(); }
}
}
}
}
JAVAD3_ChatClient.java (수업 실습 원본 — 보내기/받기 스레드 분리)
package hk.edu20260828.day19;
public class D3_ChatClient {
public static void main(String[] args) {
try {
Socket socket = new Socket("localhost", 12345);
BufferedReader keyboard = new BufferedReader(new InputStreamReader(System.in, "MS949"));
PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
System.out.print("사용할 대화명을 입력하세요: ");
String nickname = keyboard.readLine();
out.println(nickname); // 서버에 닉네임 전송(맨 처음 한 줄)
// 서버 메시지를 받는 전담 스레드(람다식) — 메인 스레드와 분리
Thread receiveThread = new Thread(() -> {
try {
String serverMessage;
while ((serverMessage = in.readLine()) != null) {
System.out.println(serverMessage);
}
} catch (IOException e) {
System.out.println("서버와의 연결이 끊어졌습니다.");
}
});
receiveThread.start();
// 메인 스레드는 키보드 입력만 계속 읽어서 서버로 전송(송신 전용)
String userInput;
while ((userInput = keyboard.readLine()) != null) {
out.println(userInput);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
동작 설명채팅 서버는 실시간 다중 접속 시나리오라 터미널 캡처 대신 흐름으로 정리합니다
[서버] 채팅 서버가 12345번 포트에서 시작됨
[클라이언트1] 사용할 대화명을 입력하세요: 태경
[클라이언트2] 사용할 대화명을 입력하세요: 상원
[서버 → 전체] 태경님이 입장하셨습니다.
[서버 → 전체] 상원님이 입장하셨습니다.
[클라이언트1 입력] 안녕하세요
[서버 → 전체] [태경]안녕하세요 (클라이언트1·2 화면에 모두 출력)
[클라이언트2 입력] 반갑습니다
[서버 → 전체] [상원]반갑습니다 (클라이언트1·2 화면에 모두 출력)
클라이언트1이 보낸 메시지가 클라이언트1 자신의 화면에도 다시 표시됩니다 — broadcast()가 "명단 전체"에 뿌리기 때문에 보낸 사람도 예외가 아닙니다. 이건 실제 채팅 앱에서 흔한 방식(내가 보낸 메시지도 서버를 거쳐 돌아와서 화면에 찍힘)입니다.
19일차에 배운 것 — 핵심 정리
여러 클라이언트를 동시에 받으려면 accept()마다 새 스레드를 만들어 그 스레드에서 대화를 처리한다.
TCP는 연결을 맺어 두고 주고받지만, UDP는 패킷마다 주소를 직접 지정해서 보낸다(연결 없음).
UDP는 DatagramSocket·DatagramPacket을 쓰고, 받을 데이터 크기(버퍼)를 미리 정해야 한다.
패킷을 돌려보낼 때는 버퍼 전체 길이가 아니라 실제 받은 길이(packet.getLength())를 써야 여분의 데이터가 안 딸려 간다.
여러 스레드가 동시에 접근하는 컬렉션은 Collections.synchronizedSet(...) 같은 동기화 래퍼로 감싼다.
채팅처럼 양방향 실시간 통신은 "보내는 스레드"와 "받는 스레드"를 분리해야 한쪽이 막혀도 다른 쪽이 계속 동작한다.
서버가 접속자 전체에게 같은 메시지를 보내는 것을 브로드캐스트라고 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
D1_MultiTcpServer에서 new ServerThread(clientSocket).start(); 를 .run(); 으로 바꾸면 어떻게 될까?
18일차 서버로 되돌아갑니다.run()을 직접 부르면 새 스레드 없이 main 스레드가 그 클라이언트와의 대화 루프(readLine())를 직접 돌기 때문에, 그 클라이언트가 연결을 끊을 때까지 다음 accept()로 돌아가지 못합니다. 그동안 B는 연결 대기열에서 기다리기만 합니다 — 16일차의 "start()와 run()" 함정이 서버에서 나타난 모습입니다.
D2_UDPServer가 돌려보낸 메시지 뒤에 왜 여분의 데이터가 붙고, 어떻게 고칠까?
답장 패킷을 new DatagramPacket(buffer, buffer.length, address, port)로 만들어 받은 길이가 아니라 버퍼 512바이트 전체를 보내기 때문입니다. 보낼 길이를 packet.getLength()(실제로 받은 바이트 수)로 바꾸면 정확히 받은 만큼만 돌아갑니다. 다만 이 코드는 다음 receive()에서 그 답장 패킷을 다시 쓰므로, 받기 전에 수신용 패킷의 길이를 buffer.length로 되돌려야(새로 만들거나 setLength) 더 긴 다음 메시지가 잘리지 않습니다.
clientWriters는 이미 synchronizedSet인데, broadcast()에서 왜 synchronized (clientWriters) 블록을 또 걸까?
synchronizedSet은 add()·remove() 같은 메서드 한 번 한 번만 동기화해 줄 뿐, for-each로 처음부터 끝까지 순회하는 동안은 보호해 주지 않습니다. 순회 도중 다른 스레드가 접속·퇴장으로 명단을 바꾸면 ConcurrentModificationException이 날 수 있습니다. 그래서 자바 문서도 순회할 때는 그 Set 자체로 synchronized 블록을 직접 걸라고 안내합니다.
💼 실무·코딩테스트에서는톰캣 같은 웹 서버가 바로 오늘 구조입니다 — 요청이 올 때마다 스레드 하나가 그 요청을 전담하는데, 접속마다 new Thread를 하면 사용자가 몰릴 때 자원이 바닥나므로 실무에서는 스레드 풀에서 꺼내 쓰고, Java 21의 가상 스레드(Virtual Thread)는 이 "요청마다 스레드" 방식을 훨씬 가볍게 만들어 줍니다. 채팅·알림 같은 실시간 기능은 요즘 브라우저와 서버 사이의 WebSocket 위에서 오늘과 같은 브로드캐스트 아이디어로 만듭니다. 면접에서는 "TCP와 UDP의 차이"(연결·신뢰성·순서 보장 vs 연결 없이 빠름 — 영상 통화·게임·DNS)와 "여러 클라이언트를 동시에 처리하려면?"을 자주 묻습니다.
TIP오늘로 자바 파트가 마무리됩니다(🧭 학습 여정의 13단계 네트워크까지 도달, 다음 날부터는 SQL·웹으로 이어집니다) — 1일차 명명법부터 시작해 클래스·상속·컬렉션·예외·람다·스레드를 거쳐 결국 "다른 컴퓨터와 대화하는 프로그램"까지 왔습니다. 채팅 서버는 사실 이 여정 전체가 합쳐진 결과물입니다: 소켓(13단계) 위에 스레드(12단계)를 얹어 여러 명을 동시에 받고, 컬렉션(11단계)으로 접속자를 관리하고, 예외 처리(11단계)로 접속 종료를 감지합니다. 복습할 때 막히는 부분이 있다면 그 부분이 가리키는 이전 단계로 돌아가 보세요 — 이 표는 그러라고 만든 지도입니다.
한 줄 요약자바 교안을 마치고 데이터베이스 과정으로 넘어온 첫날. HeidiSQL로 MariaDB(MySQL 호환)에 접속해 scott·hr·employees 실습 DB를 올리고, USE·SHOW·DESC로 구조를 파악한 뒤 SELECT ~ FROM으로 원하는 열을 꺼내는 연습(Q1~Q9)을 했다.
쉽게 말하면지금까지는 내 프로그램 안에서만 데이터를 다뤘어요 — 변수에 담고, 배열에 넣고, 파일에 쓰고. 오늘부터는 프로그램 밖에 따로 있는 거대한 창고(데이터베이스)에 말을 거는 법을 배웁니다. 자바가 "어떻게 할지"를 하나하나 지시하는 언어라면, SQL은 "무엇을 원하는지"만 말하면 되는 언어예요. "월급 2000 이하인 사람 뽑아줘"라고 쓰면 어떻게 찾을지는 DB가 알아서 합니다.
① 실습 환경 — HeidiSQL + MariaDB(MySQL 호환)
먼저 용어 정리: 수업 DB는 MariaDB입니다. MariaDB는 MySQL에서 갈라져 나온 MySQL 호환 DB라 문법·명령어가 거의 같아서, 이 노트에서 "MySQL"이라고 적은 설명도 그대로 적용됩니다. HeidiSQL은 데이터베이스에 접속해 쿼리를 쓰고 결과를 표로 보는 클라이언트 프로그램입니다. 데이터베이스 본체(MariaDB 서버)는 따로 돌아가고 있고, HeidiSQL은 거기에 접속해 명령을 전달하는 창구 역할입니다. 자바에서 Socket으로 서버에 접속하던 것(🌐 06. TCP/IP 소켓 프로그래밍)과 구조가 같습니다 — 다만 이번엔 우리가 직접 클라이언트를 만드는 대신 잘 만들어진 것을 쓰는 것이죠. 수업 파일 쿼리 #1.sql은 통째로 돌리는 파일이 아니라 한 문장씩 블록으로 골라 실행하며 결과를 보는 연습장입니다. → 🗄️ 01. HeidiSQL로 접속하기
② 창고 둘러보기 — USE · SHOW · DESC와 실습 DB 네 개
DB 서버 안에는 데이터베이스가 여러 개 있으므로 USE 디비명;으로 "지금부터 이 창고에서 일한다"를 먼저 정해야 합니다. SHOW DATABASES는 창고 목록, SHOW TABLES는 그 창고 안의 표 목록, DESC 테이블명은 표의 설계도(컬럼 이름·자료형·키)를 보여 줍니다. scott은 사원(EMP)·부서(DEPT) 테이블을 가진 고전 실습 DB로 14명의 사원 데이터가 들어 있고, hr은 좀 더 큰 인사 관리 스키마, employees는 수십만 행짜리 대용량 샘플이라 LIMIT 없이 조회하면 한참 걸립니다. 여기에 21일차에 직접 만드는 sqlDB까지 네 개를 오가며 실습합니다.
③ SELECT ~ FROM — 열 고르기 · 계산해서 새 열 만들기 · 별칭
뼈대는 SELECT 컬럼 FROM 테이블; 하나입니다. *는 전체 컬럼, 쉼표로 나열하면 적은 순서대로 나옵니다. SELECT 절에서는 계산도 할 수 있어서SAL*12처럼 표에 없던 "연봉" 열을 만들 수 있고, AS sal_year로 머리글 이름(별칭)을 붙입니다. 이때 테이블에 컬럼이 새로 생기는 게 아니라 결과 표에만 나타납니다. → 🗄️ 02. SELECT ~ FROM · 🗄️ 03. 산술 연산과 별칭(AS)
④ CONCAT — 여러 컬럼을 문장 하나로, 그리고 따옴표 함정
마지막 Q9는 여러 컬럼을 "OO님이 OO에 입사를 하고 OO의 월급을 받습니다."라는 한 문장 컬럼으로 만드는 문제였습니다. 자바처럼 +로 이으면 MySQL은 숫자 덧셈을 시도하므로 문자열 연결은 CONCAT()을 씁니다. 그리고 수업 파일의 Q9 끝에 붙은 ORDER BY '급여지급현황'은 작은따옴표로 감싼 문자열 상수라서 모든 행에 같은 값으로 정렬하는 셈 — 실제로는 아무 정렬도 일어나지 않습니다(직접 실행해 확인). 컬럼 이름에는 따옴표를 쓰지 않거나, 꼭 감싸야 하면 백틱(`)을 씁니다. → 🗄️ 04. CONCAT
SQL쿼리 #1.sql (수업 실습 원본 — 접속 직후 둘러보기)
-- use문법: 사용하고자하는 데이터베이스 선택
USE scott; -- ← 이걸 빼면 ERROR 1046 No database selected
SELECT *
FROM emp
LIMIT 20; -- ← 큰 표는 일부만 보는 습관
SHOW DATABASES; -- ← 서버에 있는 DB 목록
USE hr;
SHOW TABLES; -- ← 지금 DB 안의 테이블 목록
DESC employees; -- ← 컬럼 이름·자료형·Key(PRI = 기본키)
SHOW TABLE STATUS;
SQL쿼리 #1.sql (수업 실습 원본 — scott Q2 · Q3 · Q9)
-- Q2) 사원 테이블에서 사원의 이름(ENAME), 사원의 번호(EMPNO), 월급(SAL)을 출력하자.
SELECT ENAME, EMPNO, SAL -- ← 적은 순서대로 열이 나온다
FROM EMP;
-- Q3) 사원 테이블에서 사원의 이름과 연봉을 출력하자.
SELECT ENAME, SAL*12 AS sal_year -- ← 계산으로 만든 열 + 별칭
FROM EMP;
-- Q9) 사원 테이블의 모든 데이터를 "OO님이 0000-00-00에 입사를 하고 OO의 월급을 받습니다." 형식인 하나의 컬럼으로 출력하자.
SELECT CONCAT(ENAME, '님이 ', HIREDATE, '에 입사를 하고 ', SAL, '의 월급을 받습니다.') AS 사원정보
FROM EMP
ORDER BY '급여지급현황'; -- ← 문자열 상수라 정렬 효과 없음
실행 결과Q3 · Q9 앞 3행 (scott.sql 데이터 기준) 먼저 예측 → 펼쳐서 확인
SMITH의 월급 800 × 12 = 9600처럼 행마다 따로 계산된 값이 나옵니다. Q9는 CONCAT이 날짜(HIREDATE)와 숫자(SAL)까지 글자로 바꿔 이어 붙인 결과입니다. 두 쿼리 모두 ORDER BY가 사실상 없으므로 나오는 순서는 보장되지 않습니다 — 위 순서는 기본키(EMPNO) 순으로 저장된 상태에서 보통 그렇게 나올 뿐입니다.
20일차에 배운 것 — 핵심 정리
USE 디비명;으로 작업할 데이터베이스를 먼저 고른다 — 안 고르면 No database selected 에러.
SHOW DATABASES / SHOW TABLES / DESC 테이블명으로 구조를 파악한다. DESC 결과의 Key 열 PRI가 기본키다.
기본 뼈대는 SELECT 컬럼 FROM 테이블; — *는 전체 컬럼, 쉼표로 나열하면 적은 순서대로 나온다.
SELECT 절에서 산술 연산으로 새 열을 만들 수 있고(SAL*12), AS 별칭으로 머리글 이름을 붙인다 — 테이블은 바뀌지 않는다.
문자열 연결은 +가 아니라 CONCAT() — MySQL에서 +를 쓰면 숫자 변환을 시도해 0이 나올 수 있다.
따옴표로 감싼 이름은 컬럼이 아니라 문자열 상수다 — 수업 파일의 ORDER BY '급여지급현황'은 실제로 정렬되지 않는다(직접 실행해 확인). → 🗄️ 04번 카드
큰 테이블은 LIMIT으로 일부만 먼저 보는 습관을 들인다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
HeidiSQL을 새로 열고 USE 없이 SELECT * FROM emp;를 실행하면 어떻게 되나?
ERROR 1046 No database selected가 납니다. 서버 안에는 scott·hr·sqlDB처럼 창고가 여러 개라 어느 창고의 emp인지 알 수 없기 때문입니다. USE scott;을 먼저 실행하거나, scott.emp처럼 DB 이름을 앞에 붙여 적으면 됩니다(22일차 테이블 복사에서 scott.emp를 이렇게 씁니다).
SELECT ENAME, SAL*12 AS sal_year FROM EMP;를 실행한 뒤 DESC EMP;를 보면 sal_year 컬럼이 생겨 있을까?
생기지 않습니다.SELECT는 읽기만 하는 문장이라, 계산한 열과 별칭은 그때 돌려준 결과 표에만 존재합니다. 테이블 구조를 바꾸려면 ALTER TABLE, 값을 바꾸려면 UPDATE를 써야 합니다 — 이 구분이 23일차의 DDL/DML 분류로 이어집니다.
ORDER BY '급여지급현황'은 왜 에러도 안 나고 정렬도 안 되나?
작은따옴표 안은 컬럼 이름이 아니라 그냥 글자(문자열 상수)입니다. 그래서 문법은 맞아 에러가 없지만, 모든 행의 정렬 기준값이 똑같은 글자라 순서를 바꿀 근거가 없습니다. 별칭 기준으로 정렬하려면 ORDER BY 사원정보처럼 따옴표 없이(필요하면 백틱으로) 적어야 합니다.
💼 실무·코딩테스트에서는실무에서는 SELECT *를 피하고 필요한 컬럼만 적는 것이 기본입니다 — 컬럼이 추가되거나 순서가 바뀌어도 프로그램이 안 깨지고, 불필요한 데이터 전송도 줄어듭니다. 프로그래머스 SQL 문제는 출력 컬럼 이름·순서를 정확히 맞춰야 정답 처리되므로 AS 별칭을 자주 씁니다. 그리고 MySQL의 CONCAT은 인자 하나라도 NULL이면 결과 전체가 NULL이 되는데, 이 함정이 실제 코딩테스트에서 나왔습니다 → 코딩테스트 21회차 · 조건에 맞는 사용자 정보 조회하기.
CREATE TABLEINSERTWHERE 조건 연산자서브쿼리ANY · ALLNOT IN과 NULL
한 줄 요약sqlDB를 직접 만들어 회원·구매 테이블을 설계(CREATE)하고 데이터를 넣은(INSERT) 뒤, WHERE의 조건 연산자(비교·AND/OR·BETWEEN·IN·LIKE)로 행을 걸러내고, 마지막으로 쿼리 안에 쿼리를 넣는 서브쿼리까지 진도가 나갔다.
쉽게 말하면어제가 남이 만든 표를 읽는 법이었다면, 오늘은 표를 직접 설계하고 채우는 법입니다. 자바에서 클래스를 정의하고(설계도) 객체를 만들어(실제 데이터) 넣던 것과 정확히 같은 구조예요 — CREATE TABLE이 클래스 정의, INSERT가 객체 생성에 해당합니다. 그리고 서브쿼리는 "두 번 검색해야 알 수 있는 것을 한 번에 물어보는" 방법입니다.
① CREATE TABLE — 제약조건이 핵심
자료형(CHAR·VARCHAR·INT·SMALLINT·DATE)을 정하는 것만큼 중요한 게 제약조건입니다. NOT NULL(반드시 채워야 함), PRIMARY KEY(중복·NULL 불가), FOREIGN KEY(없는 회원의 구매는 못 넣음), AUTO_INCREMENT(번호 자동 부여)를 걸어 두면 잘못된 데이터가 애초에 들어오지 못합니다. 자바에서 private + setter로 값을 검증하던 캡슐화와 같은 목적입니다. 구매 테이블이 회원 테이블을 참조하므로 회원(부모)을 먼저 만들고 먼저 채워야 합니다. → 🗄️ 10. CREATE DATABASE · CREATE TABLE
② INSERT — 값의 순서는 컬럼 순서, 번호는 NULL로 맡기기
INSERT INTO userTbl VALUES(...)처럼 컬럼 이름을 생략하면 테이블을 만들 때 적은 컬럼 순서 그대로 값을 채워야 합니다. 구매 테이블의 첫 값을 NULL로 넣은 것은 num이 AUTO_INCREMENT라서 DB가 1, 2, 3… 번호를 대신 붙이게 하려는 것입니다. 한글 앞의 N'이승기'는 "국가별 문자(유니코드) 문자열"이라는 표시입니다. → 🗄️ 11. INSERT INTO
③ WHERE 조건 연산자 다섯 가지
비교(=>=<=), 논리(AND/OR), 범위(BETWEEN A AND B), 목록(IN), 패턴(LIKE + %_)을 차례로 실습했습니다. BETWEEN은 양 끝을 포함하고, IN은 OR를 줄여 쓴 것이며, LIKE의 _는 정확히 한 글자입니다. → 🗄️ 05. WHERE · 🗄️ 07. BETWEEN · 🗄️ 09. LIKE
④ 서브쿼리 — 단일행과 다중행, 그리고 NULL 함정
안쪽 SELECT가 값 하나를 내면 =로 비교하고, 여러 행을 내면 IN·ANY·ALL을 씁니다. 핵심은 >= ANY가 최솟값 기준, >= ALL이 최댓값 기준이 된다는 것 — 그래서 "MAX 함수를 쓰지 말고 풀어라"는 문제를 ALL로 해결할 수 있습니다. 또 "부하직원이 없는 사원" 문제에서 KING의 mgr이 NULL이라 NOT IN이 0행을 돌려주는 함정을 만났고, 수업 답은 NVL(mgr, 0)으로 NULL을 0으로 바꿔 피했습니다. → 🗄️ 12. 서브쿼리(1) · 🗄️ 13. IN · ANY · ALL · 🗄️ 14. NULL 함정
SQL쿼리 #1.sql (수업 실습 원본 — sqlDB 만들기, 일부)
CREATE DATABASE sqlDB;
USE sqlDB;
CREATE TABLE userTbl -- 회원 테이블
( userID CHAR(8) NOT NULL PRIMARY KEY, -- ← 사용자 아이디(PK): 중복·NULL 불가
name VARCHAR(10) NOT NULL, -- 이름
birthYear INT NOT NULL, -- 출생년도
addr CHAR(2) NOT NULL, -- 지역(경기,서울,경남 식으로 2글자만입력)
mobile1 CHAR(3), -- 휴대폰의 국번
mobile2 CHAR(8), -- 휴대폰의 나머지 전화번호(하이픈제외)
height SMALLINT, -- 키
mDate DATE -- 회원 가입일
);
CREATE TABLE buyTbl -- 회원 구매 테이블
( num INT AUTO_INCREMENT NOT NULL PRIMARY KEY, -- ← 순번: DB가 자동으로 매김
userID CHAR(8) NOT NULL, -- 아이디(FK)
prodName CHAR(6) NOT NULL, -- 물품명
groupName CHAR(4) , -- 분류
price INT NOT NULL, -- 단가
amount SMALLINT NOT NULL, -- 수량
FOREIGN KEY (userID) REFERENCES userTbl(userID) -- ← 없는 회원의 구매는 거부
);
INSERT INTO userTbl VALUES('LSG', N'이승기', 1987, N'서울', '011', '11111111', 182, '2008-8-8');
INSERT INTO userTbl VALUES('SSK', N'성시경', 1979, N'서울', NULL , NULL , 186, '2013-12-12');
-- ... 회원 10명
INSERT INTO buyTbl VALUES(NULL, 'KBS', N'운동화', NULL , 30, 2); -- ← num 자리에 NULL
INSERT INTO buyTbl VALUES(NULL, 'KBS', N'노트북', N'전자', 1000, 1);
-- ... 구매 12건
SQL쿼리 #1.sql (수업 실습 원본 — sqlDB에서 WHERE와 다중행 서브쿼리)
SELECT userid, name
FROM usertbl
WHERE birthyear >= 1970 AND height >= 182; -- ← 두 조건 모두 참
SELECT NAME, height
FROM usertbl
WHERE height BETWEEN 180 AND 183; -- ← 180, 183도 포함
SELECT NAME, addr
FROM usertbl
WHERE addr IN ('경남','전남','경북'); -- ← OR 세 개를 줄인 것
SELECT NAME, height
FROM usertbl
WHERE NAME LIKE '_종신'; -- ← _ 는 정확히 한 글자
-- 서브쿼리
SELECT NAME, height FROM usertbl
WHERE height >= ANY (SELECT height FROM usertbl WHERE addr = '경남'); -- ← 하나라도 넘으면
SELECT NAME, height FROM usertbl
WHERE height >= ALL (SELECT height FROM usertbl WHERE addr = '경남'); -- ← 전부 넘어야
SELECT NAME, height FROM usertbl
WHERE height IN (SELECT height FROM usertbl WHERE addr = '경남'); -- ← 같은 값만
실행 결과sqlDB 회원 10명 기준 먼저 예측 → 펼쳐서 확인
AND 182 이상 → 이승기(1987, 182), 성시경(1979, 186) 2행
BETWEEN 180 AND 183 → 이승기(182), 임재범(182) 2행
IN 경남·전남·경북 → 김범수, 김경호, 윤종신, 은지원 4행
LIKE '_종신' → 윤종신 1행
경남 회원의 키 = { 173(김범수), 170(윤종신) }
>= ANY (= 170 이상) → 조용필(166)만 빠진 9행
>= ALL (= 173 이상) → 성시경·이승기·임재범·김경호·바비킴·은지원·김범수 7행
IN (173 또는 170) → 김범수, 윤종신 2행
같은 서브쿼리 결과 {173, 170}을 두고 연산자만 바꿨는데 9행 · 7행 · 2행으로 갈립니다. >= ANY는 "둘 중 하나라도 넘으면" → 사실상 최솟값 170과 비교, >= ALL은 "둘 다 넘어야" → 최댓값 173과 비교하는 셈입니다. 임재범(1963년생, 182)은 키는 되지만 birthyear >= 1970에서 떨어져 AND 결과에 없습니다.
-- 04. 부하직원이 있는 사원의 사원번호와 이름을 출력하자.
SELECT empno, ename
FROM emp
WHERE empno IN (SELECT mgr FROM emp);
-- 05. 부하직원이 없는 사원의 사원번호와 이름을 출력하자.
-- NVL(컬럼,지정값): 해당 컬럼의 값들 중에 null을 찾아서 지정한 값으로 대체
SELECT empno, ename
FROM emp
WHERE empno NOT IN (
SELECT NVL(mgr, 0) -- ← KING의 mgr(NULL)을 0으로 바꿔야 결과가 나온다
FROM emp
);
-- 07. 20번 부서의 사원 중 가장 많은 월급을 받는 사원보다
-- 더 많은 월급을 받는 사원들의 이름과 월급을 출력하자.
-- 단, MAX함수를 사용하지 말자.(ANY, ALL 연산자)
SELECT ename, sal
FROM emp
WHERE sal > ALL (select sal from emp where deptno = 20); -- ← > ALL = 최댓값보다 크다
실행 결과scott 사원 14명 기준 먼저 예측 → 펼쳐서 확인
04 (IN mgr) → JONES, BLAKE, CLARK, SCOTT, KING, FORD 6행
05 (NOT IN NVL(mgr,0)) → SMITH, ALLEN, WARD, MARTIN, TURNER, ADAMS,
JAMES, MILLER 8행
05를 NVL 없이 실행 → (결과 없음) 0행
07 (> ALL 20번 부서) → KING 5000 1행
04와 05를 더하면 6 + 8 = 14명 전원입니다. 그런데 NVL을 빼면 05가 에러 없이 0행이 됩니다 — NOT IN (…, NULL)은 "NULL과 다르다"를 판단할 수 없어 모든 행이 참이 되지 못하기 때문입니다. 07은 20번 부서 월급 {800, 2975, 3000, 1100, 3000}의 최댓값 3000보다 큰 사람만 남습니다(3000인 SCOTT·FORD는 같으므로 제외).
21일차에 배운 것 — 핵심 정리
CHAR는 고정 길이, VARCHAR는 가변 길이. 길이가 일정한 아이디엔 CHAR, 들쭉날쭉한 이름엔 VARCHAR.
PRIMARY KEY는 중복 불가 + NULL 불가가 자동으로 걸린다. FOREIGN KEY는 부모 테이블을 먼저 만들고 먼저 넣어야 한다.
AUTO_INCREMENT 컬럼에는 INSERT할 때 NULL을 넣으면 DB가 번호를 채운다.
BETWEEN은 양 끝 포함이고 작은 값을 먼저 써야 한다 — 순서를 바꾸면 에러 없이 0행이 나온다.
LIKE에 와일드카드가 없으면 =와 동작이 같다(직접 실행해 확인). _는 한 글자, %는 0글자 이상.
> ALL (...) ≡ > (SELECT MAX(...)), > ANY (...) ≡ > (SELECT MIN(...)).
NOT IN 목록에 NULL이 있으면 결과가 무조건 0행 — 에러가 아니라 빈 결과라 놓치기 쉽다. IFNULL/COALESCE로 감싸야 한다. → 🗄️ 14. NULL 함정
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
userTbl보다 buyTbl을 먼저 만들거나, 회원보다 구매 데이터를 먼저 INSERT하면 어떻게 되나?
둘 다 거부됩니다.buyTbl의 FOREIGN KEY ... REFERENCES userTbl는 참조할 부모 테이블이 이미 있어야 만들 수 있고, 구매 행의 userID는 회원 테이블에 실제로 있는 아이디여야 들어갑니다. 외래키는 "주인 없는 구매 기록"이 생기지 않게 막는 장치라서, 부모 먼저 · 자식 나중이 원칙입니다.
05번 문제에서 NVL(mgr, 0)을 빼면 왜 에러도 없이 0행이 되나?
empno NOT IN (7902, 7698, …, NULL)은 empno <> 7902 AND … AND empno <> NULL과 같습니다. 그런데 NULL과의 비교는 참도 거짓도 아닌 "모름(UNKNOWN)"이라 AND 전체가 절대 참이 될 수 없습니다. 그래서 모든 행이 탈락합니다. NULL을 0 같은 실제 값으로 바꾸거나, 서브쿼리에 WHERE mgr IS NOT NULL을 붙이면 해결됩니다.
MAX를 쓰지 않고 "20번 부서 최고 월급보다 많이 받는 사원"을 구하려면 ANY와 ALL 중 무엇을 쓰나?
> ALL입니다. "목록의 모든 값보다 크다"는 곧 "가장 큰 값보다 크다"이기 때문입니다. > ANY는 "하나라도 넘으면 통과"라서 최솟값 800만 넘어도 나오므로 거의 모든 사원이 나와 버립니다.
💼 실무·코딩테스트에서는실무 테이블 설계에서 PK·FK·NOT NULL을 처음부터 거는 것이 데이터 품질의 출발점입니다 — 프로그램에서 아무리 검사해도 DB에 규칙이 없으면 언젠가 잘못된 값이 들어옵니다. 코딩테스트 SQL에서는 WHERE 조건 조합이 거의 모든 문제의 기본이고, 특히 LIKE '%키워드%' 검색, BETWEEN 날짜 범위, IS NULL 처리가 자주 나옵니다. "NOT IN에 NULL이 섞이면 0행"은 면접에서도 자주 묻는 단골 함정입니다.
한 줄 요약결과를 원하는 순서로 세우는 ORDER BY, 서브쿼리로 테이블을 통째로 복사하는 CREATE TABLE ~ SELECT, 그리고 여러 행을 하나로 요약하는 집계 함수와 GROUP BY·HAVING까지 나갔다.
쉽게 말하면지금까지는 표에서 원하는 칸과 줄을 골라내는 일만 했어요. 오늘부터는 골라낸 것을 가공합니다 — 줄 세우고(정렬), 통째로 복사하고, 여러 줄을 묶어 숫자 하나로 요약합니다. 자바의 반복문으로 합계를 구하던 걸 SQL에서는 SUM() 한 단어로 끝내는 셈이죠.
① ORDER BY — 앞에 적은 기준이 1순위
ORDER BY height DESC, NAME ASC는 키 큰 순으로 세우고, 키가 같으면 이름 가나다순으로 세웁니다. 두 번째 기준은 첫 번째 기준이 같을 때만 쓰입니다. 그리고 ORDER BY를 적지 않은 결과의 순서는 DB가 편한 대로라 보장되지 않습니다. → 🗄️ 15. ORDER BY
② 테이블 복사 — 제약조건은 따라오지 않는다
CREATE TABLE 새이름 (SELECT ...) 또는 CREATE TABLE 새이름 AS SELECT ...로 조회 결과를 새 테이블로 만들 수 있습니다. 다만 PK·FK·AUTO_INCREMENT는 복사되지 않아 수업 파일처럼 ALTER TABLE ... ADD CONSTRAINT로 따로 붙여야 합니다. WHERE 1 = 0처럼 항상 거짓인 조건을 주면 행은 하나도 안 오고 구조만 복사됩니다. 다른 DB의 표는 scott.emp처럼 DB 이름을 앞에 붙여 가져옵니다. → 🗄️ 16. 테이블 복사
③ 집계 함수와 NULL — 가장 자주 틀리는 지점
COUNT(*)를 뺀 모든 집계 함수는 NULL을 아예 빼고 계산합니다. 사원 14명 중 커미션이 있는 사람이 4명일 때 AVG(comm)은 4로 나눈 550이지 14로 나눈 157이 아닙니다. 문제가 "전체 사원 기준 평균"을 물었다면 AVG(IFNULL(comm,0))으로 써야 합니다. → 🗄️ 17. 집계 함수와 NULL · 🗄️ 18. GROUP BY
④ WHERE와 HAVING — 조건을 거는 두 자리
실행 순서가 FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY라는 것만 기억하면 헷갈리지 않습니다. WHERE는 묶기 전 개별 행, HAVING은 묶은 뒤 그룹을 거릅니다. 그래서 SUM(sal) > 5000 같은 집계 조건은 WHERE에 쓸 수 없습니다. 오늘 마지막 문제(Q10·Q11)는 이 네 자리를 한 문장에 모두 쓰는 연습이었습니다. → 🗄️ 19. HAVING vs WHERE
SQL쿼리 #1.sql (수업 실습 원본 — 정렬과 테이블 복사)
-- 정렬하기
USE sqldb;
SELECT NAME, height
FROM usertbl
ORDER BY height DESC, NAME ASC; -- ← 키 내림차순, 같으면 이름 오름차순
-- 테이블 복사하기: create table ~ select(서브쿼리)
-- 제약조건 등은 복사되지 않는다
CREATE TABLE buytbl2 (SELECT * FROM buytbl);
CREATE TABLE emp2 (SELECT * FROM scott.emp); -- ← 다른 DB의 표는 DB명.테이블명
-- 제약조건이 필요하면 alter를 이용해 따로 추가해준다
ALTER TABLE buytbl2
ADD CONSTRAINT pk_buytbl2_num PRIMARY KEY (num);
-- Q5) 사원 테이블의 구조만 TEST04로 복사하여 생성해보자.
CREATE TABLE test04 AS
SELECT *
FROM scott.emp
WHERE 1 = 0; -- ← 항상 거짓 → 행 0개, 구조만 복사
SQL쿼리 #1.sql (수업 실습 원본 — GROUP BY와 집계 함수 7·8번)
-- group by 절 (sqldb)
SELECT userid AS '사용자 아이디', SUM(price * amount) AS '총 구매액'
FROM buytbl
GROUP BY userid; -- ← 같은 아이디끼리 묶어 합계
USE scott;
-- 7-Q4) 사원테이블에서 부서별 평균 월급을 출력하자.
SELECT deptno, AVG(sal)
FROM emp
GROUP BY deptno;
-- 7-Q6) 사원 테이블에서 평균 커미션(COMM)을 출력하자.
SELECT AVG(comm) -- ← NULL은 빼고 4명으로 나눈다
FROM emp;
-- 8-Q5) 부서별 평균 월급을 출력하되, 평균 월급이 2000보다 큰 부서만
SELECT deptno AVG(sal) -- ← 수업 파일 원본: 쉼표 누락 → ERROR 1064
FROM emp
GROUP BY deptno
HAVING AVG(sal) > 2000; -- ← 집계 조건은 HAVING
-- 8-Q10) 30번 부서를 제외하고, 총 월급이 8000 이상인 부서를 총 월급이 높은 순으로
SELECT deptno, sum(sal)
FROM emp
WHERE deptno <> 30 -- ← 행 조건: 묶기 전에 거른다
GROUP BY deptno
HAVING SUM(sal) >= 8000 -- ← 그룹 조건: 묶은 뒤에 거른다
ORDER BY sum(sal) desc
실행 결과sqlDB 구매 12건 · scott 사원 14명 기준 먼저 예측 → 펼쳐서 확인
구매 테이블에서 BBK는 모니터 200×5 + 메모리 80×10 + 운동화 30×2 두 번 = 1920입니다 — SUM(price * amount)가 행마다 곱한 뒤 그룹별로 더한 결과죠. AVG(comm)이 157이 아니라 550인 것은 NULL인 10명이 분모에서 빠졌기 때문이고, TURNER의 커미션 0은 NULL이 아니라서 포함됩니다. 8-Q10은 30번 부서가 WHERE에서 먼저 빠지고, 남은 두 부서는 합계가 모두 8000 이상이라 둘 다 통과했습니다. 마지막 줄에 세미콜론이 없어 블록으로 골라 실행하지 않으면 다음 문장과 붙어 버리니 주의.
22일차에 배운 것 — 핵심 정리
ORDER BY a DESC, b ASC — 앞에 적은 것이 1순위. ORDER BY가 없으면 순서는 보장되지 않는다.
CREATE TABLE t AS SELECT ...는 데이터와 컬럼만 복사한다. 제약조건·인덱스는 안 따라온다.
WHERE 1 = 0은 구조만 복사하는 관용구.
COUNT(*)는 행을, COUNT(컬럼)은 NULL이 아닌 값을 센다.AVG·SUM은 NULL을 무시한다.
집계 함수는 중첩할 수 없다 — MAX(SUM(sal))은 에러. ORDER BY SUM(sal) DESC LIMIT 1로 푼다.
WHERE=행 조건, HAVING=그룹 조건. 집계 조건은 반드시 HAVING.
발견한 함정 — SELECT deptno AVG(sal)은 쉼표가 빠져 ERROR 1064. GROUP BY 없이 일반 컬럼과 집계를 섞으면 MySQL 5.7+에서 ERROR 1140/1055(MariaDB에선 통과해 실수를 못 잡는다). → 🗄️ 18. GROUP BY
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
buytbl2를 복사한 뒤 INSERT INTO buytbl2 VALUES(NULL, ...)을 하면 번호가 자동으로 붙을까?
붙지 않습니다.CREATE TABLE ~ SELECT는 컬럼과 데이터만 복사하고 AUTO_INCREMENT·PK 같은 성질은 버립니다. 그래서 수업에서도 복사 후 ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY로 PK를 다시 붙였습니다. PK·인덱스·AUTO_INCREMENT까지 같은 구조가 필요하면 CREATE TABLE 새 LIKE 원본;으로 구조를 먼저 복사하는 방법도 있습니다(외래키는 이때도 따라오지 않습니다).
"부서별 총 월급이 5000보다 큰 부서"의 조건을 WHERE SUM(sal) > 5000으로 쓰면 왜 안 되나?
WHERE는 GROUP BY보다 먼저 실행되어 아직 묶이지 않은 개별 행을 봅니다. 그 시점엔 "부서별 합계"라는 값이 존재하지 않으니 집계 함수를 쓸 수 없습니다(Invalid use of group function). 묶은 뒤에 거르는 자리인 HAVING SUM(sal) > 5000에 써야 합니다.
💼 실무·코딩테스트에서는GROUP BY + HAVING은 프로그래머스 SQL 문제의 가장 큰 단골입니다 — "카테고리별 합계", "N건 이상인 회원", "월별 매출"처럼 묶어서 세는 문제가 대부분 이 틀로 풀립니다. 실제로 이 노트의 코딩테스트 카드에도 AVG가 NULL을 분모에서 빼는 것을 놓쳐 틀린 기록이 있습니다 → 물고기별 수와 최대 길이 구하기. 실무에서는 통계·대시보드 쿼리의 뼈대이고, 결과 순서를 화면에 그대로 보여 줄 때는 반드시 ORDER BY를 명시해야 데이터가 늘어나도 순서가 흔들리지 않습니다.
한 줄 요약SQL을 DDL·DML·DCL로 분류하고, 데이터를 고치고 지우는 UPDATE·DELETE와 DELETE/TRUNCATE/DROP의 차이, 두 테이블을 잇는 JOIN, FROM 절 서브쿼리인 인라인 뷰와 WITH(CTE), 그리고 문자열·숫자·날짜 내장 함수까지 한 번에 나갔다.
쉽게 말하면조회만 하던 단계를 넘어 데이터를 실제로 바꾸는 단계입니다. 그만큼 위험해져서, WHERE 한 줄을 빠뜨리면 테이블 전체가 바뀌거나 지워집니다. 그리고 JOIN으로 흩어져 있던 두 표를 하나로 붙이는 법을 배웠는데, 이게 실무 SQL에서 가장 많이 쓰는 기능입니다.
① SQL 3분류와 "되돌릴 수 있느냐"
DDL(CREATE·ALTER·DROP·TRUNCATE)은 표의 설계도를, DML(SELECT·INSERT·UPDATE·DELETE)은 표 안의 데이터를, DCL(GRANT·REVOKE)은 권한을 다룹니다. 이 분류가 중요한 이유는 취소 가능 여부 때문입니다 — DDL은 실행 즉시 확정되고, DML은 트랜잭션 안에서만ROLLBACK할 수 있습니다. 그런데 MariaDB/HeidiSQL은 기본이 autocommit=ON이라, 그냥 실행한 DELETE도 그 자리에서 확정됩니다. 같은 시간에 AUTO_INCREMENT의 시작값(ALTER TABLE ... AUTO_INCREMENT = 100)과 증가폭(@@auto_increment_increment)을 바꾸는 법, INSERT 한 문장에 여러 행을 넣는 법도 실습했습니다. → 🗄️ 20. SQL 3분류
② JOIN — WHERE 절 서브쿼리로는 안 되는 것
사원 표에는 부서번호만 있고 부서 이름·위치는 부서 표에 있습니다. WHERE 절 서브쿼리는 "조건만 빌려오는" 것이라 부서 표의 컬럼을 결과에 출력할 수는 없습니다. 양쪽 컬럼을 함께 보여줘야 할 때는 보통 JOIN을 씁니다(SELECT 절에 (SELECT dname FROM dept d WHERE d.deptno = e.deptno) 같은 스칼라 서브쿼리를 넣는 방법도 있지만, 컬럼이 여러 개면 JOIN이 훨씬 간결하고 효율적입니다). 서브쿼리(2)의 07번 "DALLAS에서 근무하는 사원" 문제를 두 방식으로 모두 풀어 봤습니다. ON을 빠뜨리면 모든 행이 곱해지는 카티시안 곱이 됩니다. → 🗄️ 24. JOIN
③ DELETE · TRUNCATE · DROP
셋 다 "지운다"지만 다릅니다. DELETE는 조건을 걸어 골라 지우고 트랜잭션 안에서라면 되돌릴 수 있으며(기본 설정에선 바로 확정됩니다), TRUNCATE는 전체를 통째로 비우고 AUTO_INCREMENT 번호까지 초기화하며, DROP은 테이블 자체를 없앱니다. 직접 확인해 보니 DELETE 후에는 다음 번호가 4로 이어졌고 TRUNCATE 후에는 1로 돌아갔습니다. → 🗄️ 22. DELETE · TRUNCATE · DROP · 🗄️ 21. UPDATE · DELETE
④ 인라인 뷰 · CTE · 내장 함수 — 쿼리를 쪼개고 값을 다듬기
FROM 절 서브쿼리(인라인 뷰)는 별칭이 필수이고, WITH(CTE)는 그 조각을 위로 빼내 이름을 붙여 훨씬 읽기 좋게 만듭니다. 다만 CTE의 이름은 붙어 있는 한 문장 안에서만 유효해서, 다음 문장에서 다시 부르면 에러가 납니다. 마지막 시간의 내장 함수는 UPPER·LOWER·SUBSTR(시작 위치 1부터)·CHAR_LENGTH·ROUND·DATEDIFF(CURDATE(), hiredate)로 이름을 다듬고 근무일수를 계산하는 연습이었습니다. → 🗄️ 23. 서브쿼리(2) · 🗄️ 26. CTE · 🗄️ 27. 내장 함수
-- sql 분류: dml, ddl, dcl
CREATE TABLE testtbl2(
id INT AUTO_INCREMENT PRIMARY KEY,
username CHAR(3),
age INT
);
INSERT INTO testtbl2 VALUES(NULL, '정우성', 50);
-- 초기값 설정 가능
ALTER TABLE testtbl2 AUTO_INCREMENT = 100; -- ← 다음 번호를 100부터
-- 증가값 설정 가능
SET @@AUTO_INCREMENT_increment = 3; -- ← 100, 103, 106 … 처럼 3씩
-- 한문장으로 여러 데이터를 추가할 수 있다
INSERT INTO testtbl2 VALUES(NULL, '정우성', 50),
(NULL, '정우일', 50),
(NULL, '정우이', 50);
-- table 복사 후 삭제 연습
CREATE TABLE bigtbl1 (SELECT * FROM employees.employees);
CREATE TABLE bigtbl2 (SELECT * FROM employees.employees);
CREATE TABLE bigtbl3 (SELECT * FROM employees.employees);
-- 삭제하는 기능 3가지
DELETE FROM bigtbl1; -- ← table의 행들을 삭제 (DML, 한 행씩)
DROP TABLE bigtbl2; -- ← table 삭제 (DDL, 표 자체가 사라짐)
TRUNCATE TABLE bigtbl3; -- ← table의 데이터만 삭제 (DDL, 번호도 초기화)
SQL쿼리 #1.sql (수업 실습 원본 — 연습문제 9: UPDATE · DELETE)
USE scott;
CREATE TABLE emp2 AS SELECT * FROM emp; -- ← 원본 대신 복사본으로 연습
-- 1. 직업(JOB)이 'SALESMAN' 인 사원 급여(SAL)에 400 더하는 수정(UPDATE) 구문
UPDATE emp2
SET sal = sal - 400 -- ← 원본 그대로: 더하라는데 뺐다(에러 없음)
WHERE job = 'SALESMAN';
-- 2. 급여가 평균보다 높은 사원의 고용일자를 1년 더하기
UPDATE emp2
SET hiredate = ADDDATE(hiredate, INTERVAL 1 YEAR)
WHERE sal > (
SELECT avg_sal
FROM (SELECT AVG(sal) AS avg_sal FROM emp2) AS temp -- ← 한 겹 감싼 인라인 뷰
);
-- 3. COMM에 100을 더하고, 직업별로 급여를 2·3·4배
UPDATE emp2
SET comm = IFNULL(comm, 0) + 100, -- ← NULL + 100 은 NULL이라 먼저 0으로
sal = CASE job
WHEN 'CLERK' THEN sal * 2
WHEN 'MANAGER' THEN sal * 3
ELSE sal * 4
END; -- ← WHERE 없음: 전체 사원이 대상(문제 의도)
-- 4. 이름이 'M'으로 시작하는 사원 삭제
DELETE FROM emp2
WHERE ename LIKE 'M%';
INSERT INTO membertbl VALUES('BBK','비비코','미국')
ON DUPLICATE KEY UPDATE NAME = '비비코' , addr = '미국'; -- ← 이미 있으면 수정
INSERT INTO membertbl VALUES('DJM','동짜몽','일본')
ON DUPLICATE KEY UPDATE NAME = '동짜몽' , addr = '일본'; -- ← 없으면 그냥 추가
-- CTE 사용하기
WITH abc(userid, total)
AS
(SELECT userid, SUM(price*amount)
FROM buytbl
GROUP BY userid)
SELECT * FROM abc ORDER BY total DESC; -- ← 하나의 묶음으로 실행
SELECT * FROM abc ORDER BY total DESC; -- ← 재실행은 안됨
실행 결과CTE를 한 문장으로 실행한 뒤, 이름만 다시 불렀을 때 먼저 예측 → 펼쳐서 확인
+--------+-------+
| userid | total |
+--------+-------+
| BBK | 1920 |
| KBS | 1210 |
| JYP | 200 |
| EJW | 95 |
| SSK | 75 |
+--------+-------+
SELECT * FROM abc ORDER BY total DESC;
→ ERROR 1146 (42S02): Table 'sqldb.abc' doesn't exist
abc는 바로 뒤에 붙은 SELECT 한 문장 동안만 존재하는 임시 이름이라, 문장이 끝나는 세미콜론과 함께 사라집니다. 두 번째 줄은 DB 입장에서 그냥 abc라는 테이블을 찾는 것이라 "테이블이 없다"(1146)고 답합니다. 여러 문장에서 계속 쓰고 싶다면 26일차의 VIEW로 저장해야 합니다.
23일차에 배운 것 — 핵심 정리
DDL(CREATE·ALTER·DROP·TRUNCATE)은 자동 커밋이라 롤백이 안 된다. DML(SELECT·INSERT·UPDATE·DELETE)은 트랜잭션 안에서만 취소(ROLLBACK)할 수 있다 — MariaDB/HeidiSQL은 기본이 autocommit=ON이라 그냥 실행한 DELETE는 즉시 확정되어 되돌릴 수 없고, 먼저 START TRANSACTION;을 실행했거나 SET autocommit = 0;으로 꺼 둔 경우에만 ROLLBACK이 된다. DCL은 GRANT·REVOKE.
UPDATE·DELETE에서 WHERE를 빠뜨리면 전체 행이 대상이 된다 — 실행 전 같은 조건으로 SELECT를 먼저 돌려 볼 것.
MySQL은 수정 대상 테이블을 서브쿼리에서 직접 못 읽는다(ERROR 1093) — FROM (SELECT ...) AS temp로 감싸면 우회된다. (수업 DB인 MariaDB는 10.3부터 이 형태를 허용해 바로 써도 실행되지만, MySQL로 옮기면 에러가 나므로 감싸 두는 편이 안전하다.)
DELETE는 번호가 이어지고 TRUNCATE는 AUTO_INCREMENT가 1로 초기화된다.
JOIN ... ON으로 두 테이블을 잇는다. 테이블 별칭(emp e)을 쓰면 짧고 컬럼 구분이 쉽다.
FROM 절 서브쿼리에는 별칭이 필수 — 빠뜨리면 ERROR 1064.
INSERT ... ON DUPLICATE KEY UPDATE — 있으면 수정, 없으면 추가.
SUBSTR의 시작 위치는 1부터(자바 인덱스와 다름). ROUND(값, n)의 n은 남길 소수 자릿수.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
HeidiSQL에서 DELETE FROM emp2;를 실수로 실행했다. 바로 ROLLBACK;을 치면 살릴 수 있나?
기본 설정에서는 못 살립니다. MariaDB/HeidiSQL은 autocommit=ON이라 DELETE가 끝나는 순간 이미 커밋(확정)됐고, ROLLBACK은 되돌릴 대상이 없습니다. 미리 START TRANSACTION;을 실행해 두었거나 SET autocommit = 0;으로 꺼 둔 경우에만 되돌릴 수 있습니다. 그래서 복사본(emp2)에서 연습하고, 실행 전에 같은 WHERE로 SELECT를 먼저 돌려 보는 습관이 중요합니다.
연습문제 3번에서 comm = comm + 100으로만 쓰면 누가 어떻게 되나?
커미션이 NULL인 사원(SMITH, JONES 등 10명)은 그대로 NULL로 남습니다. NULL에 무엇을 더해도 NULL이기 때문입니다. 그래서 수업 답은 IFNULL(comm, 0) + 100으로 NULL을 먼저 0으로 바꾼 뒤 더했습니다 — 22일차 "집계 함수는 NULL을 뺀다"와 함께 기억해 둘 NULL 규칙입니다.
로그 테이블을 비우고 번호도 1부터 다시 시작하고 싶다. DELETE와 TRUNCATE 중 무엇을 쓰나?
TRUNCATE입니다. DELETE FROM 테이블;은 행만 지우고 AUTO_INCREMENT 번호는 이어집니다(다음이 4라면 4부터). TRUNCATE는 표를 통째로 비우면서 번호까지 초기화합니다. 대신 DDL이라 조건을 걸 수 없고 되돌릴 수도 없다는 점을 기억하세요.
💼 실무·코딩테스트에서는실무에서 운영 DB에 UPDATE·DELETE를 직접 칠 때는 ① 같은 조건으로 SELECT해 대상 건수 확인 → ② 트랜잭션 시작 → ③ 실행 후 영향받은 행 수 확인 → ④ COMMIT 순서가 기본입니다. 면접에서는 "DELETE·TRUNCATE·DROP의 차이"와 "트랜잭션이 필요한 일상 사례(계좌 이체 등)"가 단골입니다(수업 중에도 언급됨). 코딩테스트 SQL에서는 JOIN이 거의 매번 등장하고, DATEDIFF·DATE_FORMAT·SUBSTR 같은 내장 함수로 출력 형식을 맞추는 문제가 많습니다.
한 줄 요약행을 합치지 않고 집계값을 옆 칸에 붙이는 OVER() 윈도우 함수를 배웠다. 순위 함수 세 가지(ROW_NUMBER·RANK·DENSE_RANK)의 동점 처리 차이, 앞뒤 행을 끌어오는 LEAD·LAG, 그리고 세로 데이터를 가로로 펼치는 피벗까지 다뤘다.
쉽게 말하면GROUP BY는 여러 줄을 한 줄로 접어 버려서 사원 이름이 사라졌어요. 윈도우 함수는 줄은 그대로 두고 칸만 늘려 "내 점수 옆에 반 평균"을 같이 보여줍니다. 성적표를 떠올리면 정확합니다.
① OVER(PARTITION BY) — 행을 접지 않는 GROUP BY
AVG(sal) OVER(PARTITION BY deptno)는 부서별 평균을 내되 사원 14명을 그대로 남기고, 같은 부서 사원 옆에 같은 평균값을 붙여 줍니다. "내 월급과 우리 부서 평균"을 한 줄에 놓고 비교할 수 있는 것이죠. OVER() 괄호 안의 PARTITION BY는 어디서 끊어서 계산할지, ORDER BY는 어떤 순서로 줄 세워 계산할지를 정합니다. → 🗄️ 28. 윈도우 함수 기초
② 순위 함수 세 가지의 차이 — 동점자가 나올 때
키 182인 사람이 두 명일 때 ROW_NUMBER는 2·3으로 억지로 가르고, RANK는 둘 다 2등 뒤 3을 건너뛰어 4로, DENSE_RANK는 둘 다 2등 뒤 3으로 이어집니다. "6~10등만 뽑기" 같은 페이지 나누기에는 중복이 없는 ROW_NUMBER를 씁니다. NTILE(4)는 줄 선 사람들을 4개 반으로 나눠 반 번호를 매깁니다. → 🗄️ 29. 순위 함수
③ 윈도우 함수는 WHERE에서 쓸 수 없다 · LEAD/LAG
실행 순서상 WHERE가 윈도우 함수보다 먼저 동작하기 때문입니다. 그래서 "순위가 6~10등인 사람"을 구하려면 인라인 뷰로 한 겹 감싼 뒤 바깥에서 WHERE RN BETWEEN 6 AND 10으로 걸러야 합니다. LAG(sal, 1)은 바로 앞 행의 값, LEAD(sal, 1)은 바로 다음 행의 값을 옆 칸으로 끌어옵니다 — "직전 입사자의 급여", "한 단계 낮은 사원의 급여" 문제가 이것이었습니다. → 🗄️ 23. 인라인 뷰 · 🗄️ 30. 분석 함수
④ 피벗 — MySQL에는 PIVOT 문법이 없다
엑셀 피벗 테이블처럼 세로로 쌓인 데이터를 가로로 펼치는 일인데, MySQL에는 전용 문법이 없어 SUM(IF(조건, 값, 0))을 칸 수만큼 나열해 흉내 냅니다. 문자열을 펼칠 때는 SUM 대신 MAX(IF(...))를 씁니다 — 연습문제 3번 SALGRADE 가로 출력이 이 경우였습니다. → 🗄️ 31. 피벗
SQL쿼리 #1.sql (수업 실습 원본 — 윈도우 함수, sqldb 회원 키 순위)
-- 윈도우 함수 사용: 집계 대상 데이터외에 다른 데이터도 표현
USE scott;
SELECT ename, deptno,
ROUND( AVG(sal) OVER(PARTITION BY deptno), 2) AS "avg_sal" -- ← 행은 그대로, 부서 평균만 옆에
FROM emp
GROUP BY deptno, ename;
USE sqldb;
-- 3~8등만 고르기: 순위는 안쪽에서 만들고, 거르기는 바깥에서
SELECT *
FROM(
SELECT ROW_NUMBER() OVER(ORDER BY height DESC) `키큰순위`,
NAME, addr, height
FROM usertbl ) a -- ← 인라인 뷰 별칭 필수
WHERE `키큰순위` BETWEEN 3 AND 8;
-- dense_rank() / rank() 사용하기
SELECT dense_rank() OVER(ORDER BY height DESC) `키큰순위`, NAME, addr, height
FROM usertbl;
SELECT rank() OVER(ORDER BY height DESC) `키큰순위`, NAME, addr, height
FROM usertbl;
-- lead() 함수: 다음행에 오는 값 구하기
SELECT NAME, addr, height AS "키",
height - (LEAD(height,1) OVER(ORDER BY height DESC)) AS "다음 사람과 키차이"
FROM usertbl;
실행 결과키 순위 상위 4명 — 세 순위 함수를 나란히 놓으면 먼저 예측 → 펼쳐서 확인
이름 키 ROW_NUMBER RANK DENSE_RANK 다음 사람과 키차이
성시경 186 1 1 1 4
이승기 182 2 2 2 0
임재범 182 3 2 2 5
김경호 177 4 4 3 1
...
조용필 166 10 10 9 NULL
키 182가 두 명이라 세 함수의 차이가 그대로 드러납니다 — ROW_NUMBER는 2·3으로 갈랐고, RANK는 2·2 뒤 3을 건너뛰고 4, DENSE_RANK는 2·2 뒤 바로 3입니다. 동점인 이승기·임재범 중 누가 2번이 될지는 정렬 기준이 키 하나뿐이라 보장되지 않습니다(수업 파일의 다른 쿼리처럼 ORDER BY height DESC, NAME ASC로 기준을 하나 더 주면 고정됩니다). 맨 끝 조용필은 다음 사람이 없어서 LEAD가 NULL이고, 키차이도 NULL이 됩니다.
-- 피벗 쿼리 (pivotTest: 이름·계절·판매량 9행)
SELECT
uName,
SUM(IF(season = '봄', amount, 0)) AS '봄', -- ← 봄인 행만 amount, 아니면 0
SUM(IF(season = '여름', amount, 0)) AS '여름',
SUM(IF(season = '가을', amount, 0)) AS '가을',
SUM(IF(season = '겨울', amount, 0)) AS '겨울',
SUM(amount) AS '합계'
FROM pivotTest
GROUP BY uName;
-- 2. 급여 순위 6등~10등 (원본에는 A.ND 오타 → ERROR 1064, 아래는 고친 형태)
SELECT *
FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY sal DESC) RN
FROM emp
) a
WHERE RN BETWEEN 6 AND 10; -- ← 윈도우 함수 결과는 바깥 WHERE에서
-- 3. SALGRADE 세로 정보를 가로로 (문자열이라 MAX)
SELECT
MAX(IF(grade = 1, CONCAT(losal, '-', hisal), NULL)) AS '1등급',
MAX(IF(grade = 2, CONCAT(losal, '-', hisal), NULL)) AS '2등급',
MAX(IF(grade = 3, CONCAT(losal, '-', hisal), NULL)) AS '3등급',
MAX(IF(grade = 4, CONCAT(losal, '-', hisal), NULL)) AS '4등급',
MAX(IF(grade = 5, CONCAT(losal, '-', hisal), NULL)) AS '5등급'
FROM salgrade;
-- 7. 입사일 순으로 줄 세웠을 때 직전 입사자의 급여
SELECT ename, hiredate, sal,
LAG(sal,1) OVER(ORDER BY hiredate asc) AS "이전 사원 급여" -- ← 바로 앞 행의 sal
FROM emp;
실행 결과피벗 · SALGRADE 가로 · LAG 앞 4행 먼저 예측 → 펼쳐서 확인
uName 봄 여름 가을 겨울 합계
김범수 40 14 25 32 111
윤종신 0 79 0 40 119
1등급 2등급 3등급 4등급 5등급
700-1200 1201-1400 1401-2000 2001-3000 3001-9999
ename hiredate sal 이전 사원 급여
SMITH 1980-12-17 800 NULL
ALLEN 1981-02-20 1600 800
WARD 1981-02-22 1250 1600
JONES 1981-04-02 2975 1250
김범수의 봄은 3 + 37 = 40, 겨울은 10 + 22 = 32입니다 — 같은 계절 행이 여러 개라도 SUM이 합쳐 주고, 다른 계절 행은 IF가 0을 내서 영향이 없습니다. SALGRADE는 등급마다 행이 하나뿐이라 MAX(IF(..., NULL))이 NULL이 아닌 그 한 값을 골라냅니다. LAG의 첫 행이 NULL인 것은 SMITH보다 먼저 입사한 사람이 없기 때문입니다.
24일차에 배운 것 — 핵심 정리
함수() OVER(PARTITION BY 컬럼) — PARTITION BY는 행을 합치지 않는 GROUP BY다.
LAG=이전 행, LEAD=다음 행. 첫 행의 LAG와 끝 행의 LEAD는 NULL이며, 세 번째 인자로 기본값을 줄 수 있다.
윈도우 함수는 WHERE에서 못 쓴다 — 인라인 뷰로 감싸서 거른다.
피벗은 SUM(IF(조건, 값, 0))을 나열해서 만든다. 문자열은 MAX(IF(조건, 값, NULL)).
발견한 오타 둘 — BETWEEN 6 A.ND 10은 ERROR 1064, FIRST_VALUE(height,1)은 인자가 하나뿐이라 ERROR 1064다(LEAD/LAG와 헷갈리기 쉽다). → 🗄️ 30. 분석 함수
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
"급여 6~10등"을 SELECT *, ROW_NUMBER() OVER(...) RN FROM emp WHERE RN BETWEEN 6 AND 10; 한 문장으로 쓰면 왜 안 되나?
실행 순서가 FROM → WHERE → … → SELECT라서, WHERE가 돌 때는 SELECT 절의 RN이 아직 계산되지 않았습니다. 그래서 "그런 컬럼이 없다"는 에러가 납니다. 순위를 안쪽 쿼리(인라인 뷰)에서 먼저 확정시키고, 바깥 쿼리의 WHERE에서 그 결과를 거르는 두 단계가 필요합니다.
"공동 2등이 둘이면 다음 사람은 3등"으로 매기고 싶다. 어느 함수를 쓰나?
DENSE_RANK()입니다. RANK()는 공동 2등이 둘이면 두 자리를 차지했다고 보고 다음을 4등으로 매기고(올림픽 메달 방식), DENSE_RANK()는 번호를 건너뛰지 않고 3등으로 이어 갑니다. 중복 없이 1, 2, 3… 을 반드시 붙여야 하면(페이지 나누기) ROW_NUMBER()입니다.
💼 실무·코딩테스트에서는ROW_NUMBER는 이 과정의 게시판 페이징에서 그대로 다시 나옵니다 — 글마다 ROW_NUMBER로 번호를 매긴 뒤 ceil(rn/10) = 페이지번호로 한 페이지 분량을 고르는 방식입니다. 실무에서는 "고객별 최근 주문 1건"(PARTITION BY 고객 ORDER BY 주문일 DESC 후 RN = 1), "전월 대비 증감"(LAG) 같은 리포트에 매일 쓰입니다. 코딩테스트 SQL에서도 "그룹별 최댓값을 가진 행 전체"를 구하는 문제를 윈도우 함수로 깔끔하게 풀 수 있습니다.
한 줄 요약테이블 두 개를 =로 잇던 기본 조인에서 나아가, 세 개 이상 이어 붙이기, 같은 테이블을 두 번 부르는 셀프 조인, BETWEEN으로 잇는 비등가 조인, 그리고 짝 없는 행까지 살리는 OUTER JOIN을 배웠다.
쉽게 말하면조인은 흩어진 표를 붙이는 접착제인데, 붙이는 방법이 상황마다 다릅니다. 사원과 그 사원의 관리자는 둘 다 같은 사원 표에 있으니 표 하나를 두 번 불러 붙여야 하고(셀프 조인), 급여 등급은 "얼마부터 얼마까지"라는 구간이니 = 대신 BETWEEN으로 붙입니다(비등가 조인).
① OUTER JOIN — 짝이 없는 쪽도 살리기
INNER JOIN은 양쪽에 짝이 있는 행만 남깁니다. sqlDB에서 회원과 구매를 이으면 한 번도 안 산 회원 5명은 통째로 사라지죠. usertbl u LEFT OUTER JOIN buytbl b로 쓰면 왼쪽(회원)을 전부 살리고, 짝이 없으면 구매 쪽 칸을 NULL로 채웁니다. 여기에 WHERE b.prodName IS NULL을 붙이면 "구매 이력이 없는 회원만" 골라낼 수 있습니다. LEFT와 RIGHT는 어느 쪽을 기준으로 살리느냐만 다릅니다. → 🗄️ 33. OUTER JOIN · 🗄️ 38. OUTER JOIN 실전
② 다중 조인 · 다대다 — 연결 테이블을 사이에 두고
학생 한 명이 동아리 여러 개에, 동아리 하나에 학생 여러 명이 드는 다대다 관계는 stdtbl·clubtbl 사이에 연결 테이블 stdclubtbl을 두고 JOIN ... ON ... JOIN ... ON ...으로 세 표를 차례로 이어 붙였습니다. scott에서도 사원·부서·급여등급 세 표를 이어 "부서이름 + 급여등급"을 한 줄에 출력했습니다(12번 Q7·Q8). → 🗄️ 37. 다대다 관계 · 🗄️ 32. JOIN 심화
③ 셀프 조인 · 비등가 조인 — 별칭과 구간
같은 emp 테이블을 e1(사원)·e2(관리자)로 다른 이름을 붙여 서로 다른 표인 것처럼 다룹니다. 별칭이 없으면 어느 쪽 ename인지 구분할 수 없습니다. 다만 결과는 13명만 나옵니다 — 사장 KING은 mgr이 NULL이라 짝이 없어 INNER JOIN에서 빠지기 때문입니다. 급여등급(salgrade)은 losal~hisal구간으로 저장돼 있어 ON e.SAL BETWEEN s.LOSAL AND s.HISAL처럼 =가 아닌 조건으로 잇습니다.
④ 그리고 COUNT(*)의 함정
"부서별 사원 수"를 INNER JOIN으로 구하면 사원이 없는 OPERATIONS 부서가 통째로 사라집니다. RIGHT OUTER JOIN으로 부서를 전부 살리면 4개 부서가 다 나오는데, OUTER JOIN이 OPERATIONS를 NULL로 채운 한 줄로 만들어 두기 때문에 COUNT(*)는 그 줄을 세어 1을 돌려줍니다. 실제 사원은 0명이죠. COUNT(e.ename)처럼 짝 쪽 컬럼을 세야 NULL이 빠져 0이 나옵니다. 수업 파일의 두 번째 답이 COUNT(e.EName)인 이유입니다.
-- 구매이력이 없는 회원 조회할 경우
SELECT u.userid,u.name,b.prodName, u.addr,
CONCAT(u.mobile1, u.mobile2) AS "연락처"
FROM usertbl u
LEFT OUTER JOIN buytbl b -- ← 왼쪽(회원)을 전부 살린다
ON u.userID = b.userID
WHERE b.prodName IS NULL -- ← 짝이 없어 NULL로 채워진 줄만
ORDER BY u.userID;
-- 실습 6 : 학생 ↔ 연결 테이블 ↔ 동아리
SELECT c.clubname, c.roomno, s.stdname, s.addr
FROM stdtbl s JOIN stdclubtbl sc
ON s.stdname = sc.stdname -- ← 1단계: 학생 + 가입 기록
JOIN clubtbl c
ON sc.clubname = c.clubname -- ← 2단계: + 동아리 정보
ORDER BY c.clubname;
구매 테이블에 등장하는 회원은 KBS·JYP·BBK·SSK·EJW 다섯 명뿐이라, 나머지 다섯 명이 NULL과 짝지어진 채 남았고 IS NULL이 그들만 골랐습니다. 윤종신의 연락처가 NULL인 것은 휴대폰 번호가 NULL이어서 CONCAT에 NULL이 하나라도 섞이면 결과 전체가 NULL이 되기 때문입니다(20일차 CONCAT의 함정이 여기서 다시 나옵니다).
-- Q6) 커미션이 책정된 사원들의 사원번호, 이름, 연봉, 연봉+커미션, 급여등급
SELECT e.empno, e.ENAME, e.sal, e.sal*12, e.sal*12+e.comm, s.grade
FROM emp e JOIN salgrade s
ON e.SAL BETWEEN s.LOSAL AND s.HISAL -- ← 비등가 조인: 구간에 들어가면 짝
WHERE e.comm IS NOT NULL;
-- Q9) 사원번호와 사원이름, 그 사원을 관리하는 관리자의 사원번호와 사원이름
SELECT e1.empno, e1.ename, e2.empno, e2.ename
FROM emp e1
JOIN emp e2 -- ← 같은 표를 다른 별칭으로 한 번 더
ON e1.mgr = e2.empno; -- ← 내 관리자번호 = 상대의 사원번호
-- Q10) 부서이름, 위치, 사원 수, 평균 급여
SELECT d.dname "DNAME", d.loc "LOC",
COUNT(*) "NUMBER OF PEOPLE", round(AVG(sal)) "SALARY"
FROM emp e JOIN dept d -- ← INNER: OPERATIONS가 사라진다
ON e.deptno = d.deptno
GROUP BY d.dname;
SELECT d.dname "DNAME", d.loc "LOC",
COUNT(e.EName) "NUMBER OF PEOPLE", round(AVG(sal)) "SALARY"
FROM emp e RIGHT OUTER JOIN dept d -- ← 부서를 전부 살리고
ON e.deptno = d.deptno
GROUP BY d.dname; -- ← COUNT는 짝 쪽 컬럼으로
실행 결과Q6 · Q9 · Q10 (scott 기준) 먼저 예측 → 펼쳐서 확인
[Q6] ALLEN 1600 → 19200, 19500, 3등급
WARD 1250 → 15000, 15500, 2등급
MARTIN 1250 → 15000, 16400, 2등급
TURNER 1500 → 18000, 18000, 3등급 4행
[Q9] SMITH→FORD, ALLEN→BLAKE, WARD→BLAKE, JONES→KING, … 13행 (KING 없음)
[Q10 INNER + COUNT(*)] [Q10 RIGHT OUTER + COUNT(e.EName)]
ACCOUNTING NEW YORK 3 2917 ACCOUNTING NEW YORK 3 2917
RESEARCH DALLAS 5 2175 OPERATIONS BOSTON 0 NULL
SALES CHICAGO 6 1567 RESEARCH DALLAS 5 2175
SALES CHICAGO 6 1567
Q6의 WARD(1250)는 1201~1400 구간이라 2등급으로 이어졌습니다 — =로는 절대 붙일 수 없는 짝입니다. Q9는 14명 중 관리자가 없는 KING만 빠진 13행입니다. Q10 오른쪽은 OPERATIONS가 살아났고, COUNT(e.EName)이라서 0, 평균은 더할 값이 없어 0이 아니라 NULL입니다. 같은 자리에 COUNT(*)를 썼다면 NULL로 채운 줄까지 세어 1이 나왔을 것입니다.
25일차에 배운 것 — 핵심 정리
다중 조인 — JOIN ... ON ... JOIN ... ON ...으로 계속 이어 붙인다.
다대다 관계는 가운데 연결 테이블을 두고 일대다 두 개로 쪼개서 잇는다.
셀프 조인 — 같은 테이블을 서로 다른 별칭으로 두 번 부른다. 사원↔관리자 같은 자기 참조 관계에 쓴다.
비등가 조인 — ON e.sal BETWEEN s.losal AND s.hisal처럼 범위로 잇는다. 조인 조건이 꼭 =일 필요는 없다.
LEFT/RIGHT OUTER JOIN — 기준 쪽 테이블을 전부 남기고, 짝이 없으면 반대쪽을 NULL로 채운다.
OUTER JOIN 뒤의 COUNT(*)는 함정 — NULL 줄도 세므로 COUNT(짝쪽_컬럼)으로 세야 한다.
짝이 없는 행은 AVG·SUM도 0이 아니라 NULL이 된다.
"한 건도 없는 것"을 찾으려면 LEFT JOIN 후 WHERE 오른쪽컬럼 IS NULL.
MySQL에는 FULL OUTER JOIN이 없어 LEFT 결과와 RIGHT 결과를 UNION으로 합쳐 흉내 낸다(실습 7).
테이블을 정규화해서 나눴기 때문에 조인이 필요해진다 — 다만 조인이 많으면 성능이 떨어진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
셀프 조인 결과에 KING까지 포함해 14명을 모두 보이게 하려면 어떻게 고치나?
FROM emp e1 LEFT OUTER JOIN emp e2 ON e1.mgr = e2.empno로 바꿉니다. 사원 쪽(e1)을 기준으로 전부 살리면, 관리자가 없는 KING은 관리자 칸이 NULL인 채로 결과에 남습니다. INNER JOIN은 짝이 없는 행을 버리기 때문에 13행이었던 것입니다.
LEFT JOIN 후 WHERE b.prodName IS NULL로 거르는 대신 ON 절에 AND b.prodName IS NULL을 붙이면 결과가 같을까?
다릅니다.ON은 "어떤 행끼리 짝지을지"를 정하는 조건이라, 거기에 조건을 더하면 짝이 될 수 있는 구매 행이 없어질 뿐 왼쪽 회원은 전원(10명)이 그대로 남습니다. "짝이 없는 것만 남기기"는 조인이 끝난 결과를 거르는 WHERE에서 해야 원하는 5명이 나옵니다.
💼 실무·코딩테스트에서는"한 번도 ~하지 않은 회원/상품"을 찾는 문제는 프로그래머스 SQL에서 아주 자주 나오며, 정석 풀이가 LEFT JOIN … WHERE 오른쪽 IS NULL입니다(NOT IN으로 풀다 NULL 함정에 걸리기 쉽습니다). 실무 리포트에서는 "0건인 항목도 목록에 보여야 하는" 경우가 많아, 집계 전에 OUTER JOIN으로 기준 목록을 살리고 COUNT(짝쪽 컬럼)·IFNULL(SUM(...), 0)으로 0을 표시하는 패턴을 자주 씁니다.
ALTER TABLECHECK · FOREIGN KEYCASCADEVIEW클러스터형 인덱스
한 줄 요약테이블을 제대로 설계하는 쪽으로 넘어왔다. 제약조건을 ALTER로 나중에 붙이는 법과 CHECK·외래키 옵션(CASCADE), 자주 쓰는 조회를 저장하는 VIEW, 그리고 검색을 빠르게 하는 인덱스(클러스터형 vs 보조)를 배웠다.
쉽게 말하면지금까지가 데이터를 다루는 법이었다면 오늘은 데이터가 망가지지 않게 지키는 법입니다. 제약조건은 데이터베이스가 대신 지켜 주는 규칙이라, 프로그램이 여러 개여도 규칙은 한 곳에서만 관리됩니다. 인덱스는 책의 목차와 찾아보기예요 — 목차(클러스터형)는 하나뿐이고 찾아보기(보조)는 여러 개 만들 수 있습니다.
① 제약조건은 나중에도 붙일 수 있다 — 단, 이미 든 데이터가 규칙을 지켜야
ALTER TABLE ... ADD CONSTRAINT로 PK·FK·CHECK를 뒤늦게 붙일 수 있습니다. 순서가 중요한데, 참조되는 쪽에 PK가 먼저 있어야 다른 테이블이 FK로 참조할 수 있습니다. 교재 실습에서는 일부러 제약조건 없이 테이블을 만들고 데이터를 먼저 넣었는데, 구매 테이블에 회원 테이블에 없는 bbk가 들어 있어서 FK를 붙이기 전에 그 행을 지워야 했습니다. 이후 SET foreign_key_checks = 0으로 검사를 잠시 끄고 넣은 뒤 다시 켜는 것도 해 봤는데, 이건 규칙을 어긴 데이터를 남기는 위험한 방법입니다. CHECK는 값의 범위를 강제해서, 범위를 벗어나면 ERROR 4025로 거부합니다. → 🗄️ 34. 제약조건 심화
② ON UPDATE / ON DELETE CASCADE — 부모를 따라가는 자식
외래키에 아무 옵션이 없으면 자식이 참조 중인 부모는 수정·삭제 자체가 거부됩니다. 기존 FK를 DROP FOREIGN KEY로 지우고 ON UPDATE CASCADE ON DELETE CASCADE를 붙여 다시 만들면, 회원 아이디 bbk를 vvk로 바꿀 때 구매 기록의 아이디도 함께 바뀌고, 회원을 지우면 그 회원의 구매 기록도 같이 지워집니다. 편리하지만 지우는 쪽은 되돌릴 수 없는 연쇄 삭제라는 점을 기억해야 합니다. → 🗄️ 39. CASCADE
③ VIEW — CTE와 무엇이 다른가
WITH(CTE)는 그 한 문장에서만 쓰는 임시 이름이고, 뷰는 데이터베이스에 저장해 두고 계속 쓰는 이름입니다. 데이터를 복사하는 게 아니라 조회할 때마다 원본을 읽는 가상 테이블이라 원본이 바뀌면 결과도 바뀝니다. 보안에도 쓰입니다 — 급여를 뺀 뷰만 주면 급여를 볼 수 없죠. 뷰를 통해 데이터를 넣으면 원본 테이블에 들어가므로 원본의 제약조건을 확인해야 합니다. → 🗄️ 35. VIEW
④ 클러스터형 인덱스는 저장 순서를 바꾼다
PRIMARY KEY를 걸면 클러스터형 인덱스(테이블당 1개)가, UNIQUE를 걸면 보조 인덱스가 자동으로 생깁니다(SHOW INDEX로 확인). ORDER BY RAND()로 일부러 뒤죽박죽 섞어 만든 테이블에 PK를 붙이고 다시 조회하면, ORDER BY를 쓰지 않았는데도 오름차순으로 나옵니다. 클러스터형 인덱스가 실제 저장 순서를 바꿔 놓기 때문입니다. 다만 이건 보장되는 순서가 아니므로 정렬이 필요하면 ORDER BY를 명시해야 합니다. → 🗄️ 36. 인덱스
ALTER TABLE usertbl
ADD CONSTRAINT pk_usertbl_userid
PRIMARY KEY (userid); -- ← 참조될 쪽에 PK부터
DELETE FROM buytbl WHERE userid = 'bbk'; -- ← 회원 표에 없는 bbk 구매가 있으면 FK가 안 붙는다
ALTER TABLE buytbl
ADD CONSTRAINT fk_usertbl_buytbl
FOREIGN KEY (userid)
REFERENCES usertbl (userid);
SET foreign_key_checks = 0; -- ← 검사를 잠시 끄고 (위험)
INSERT INTO buytbl VALUES(NULL, 'bbk', N'모니터', N'전자', 200, 5);
-- ... 구매 데이터 나머지
SET foreign_key_checks = 1; -- ← 반드시 다시 켠다
SET check_constraint_checks = 0;
ALTER TABLE usertbl
ADD CONSTRAINT ck_birthyear
CHECK ( (birthyear >= 1900 AND birthyear <= 2020) AND (birthyear IS NOT NULL) );
SET check_constraint_checks = 1; -- ← 이후 1900~2020 밖의 값은 ERROR 4025
ALTER TABLE buytbl
DROP FOREIGN KEY fk_usertbl_buytbl; -- ← 옵션을 바꾸려면 지우고 다시 만든다
ALTER TABLE buytbl
ADD CONSTRAINT fk_usertbl_buytbl
FOREIGN KEY(userid)
REFERENCES usertbl (userid)
ON UPDATE CASCADE -- ← 부모 아이디가 바뀌면 자식도
ON DELETE CASCADE; -- ← 부모가 지워지면 자식도
UPDATE usertbl SET userid = 'vvk' WHERE userid = 'bbk'; -- ← buytbl의 bbk도 vvk로
DELETE FROM usertbl WHERE userid = 'vvk'; -- ← vvk의 구매 기록도 함께 삭제
-- view 사용하기
CREATE VIEW V_USERtbl
AS SELECT userid, NAME FROM usertbl; -- ← 아이디·이름만 보이는 창
SELECT * FROM v_usertbl; -- ← 일반 테이블처럼 조회
-- 클러스터형 인덱스로 지정한 열을 기준으로 정렬된다.
CREATE TABLE usertbl3(SELECT * FROM usertbl ORDER BY RAND()); -- ← 일부러 섞어서 복사
ALTER TABLE usertbl3
ADD PRIMARY KEY (userid);
SELECT * FROM usertbl3; -- ← ORDER BY 없이도 userid 오름차순으로 나옴
관찰 포인트CASCADE 전과 후, 같은 UPDATE가 어떻게 달라지나
[CASCADE 없음] UPDATE usertbl SET userid='vvk' WHERE userid='bbk';
→ 거부 (ERROR 1451: Cannot delete or update a parent row:
a foreign key constraint fails ...)
수업에서는 foreign_key_checks = 0 으로 끄고 바꿨다
→ 회원은 vvk, 구매 기록은 bbk 그대로 → JOIN하면 bbk의 구매가 사라져 보임
[CASCADE 붙인 뒤] UPDATE usertbl SET userid='vvk' WHERE userid='bbk';
→ 성공, buytbl의 bbk 행들도 자동으로 vvk
DELETE FROM usertbl WHERE userid='vvk';
→ 회원과 그 구매 기록이 함께 삭제
수업 파일에서 검사를 끈 상태로 아이디를 바꾼 직후 buytbl JOIN usertbl을 조회한 이유가 이것입니다 — 검사를 끄면 짝이 끊어진 구매 기록이 생기고, INNER JOIN에서 조용히 사라져 보입니다. 에러가 나지 않으니 더 위험하죠. CASCADE는 같은 변경을 규칙을 지키면서 해 주는 정식 방법입니다.
26일차에 배운 것 — 핵심 정리
제약조건은 CREATE 때 붙이거나 ALTER TABLE ... ADD CONSTRAINT로 나중에 붙인다. FK는 보통 마지막에.
참조되는 쪽이 PK(또는 UNIQUE)여야 FK를 걸 수 있다. 이미 든 데이터가 규칙을 어기면 제약조건이 붙지 않는다.
CHECK (조건) 위반 시 ERROR 4025, FK 위반 시 ERROR 1452.
ON DELETE CASCADE·ON UPDATE CASCADE — 부모가 바뀌면 자식도 따라 바뀐다. 옵션을 바꾸려면 FK를 지우고 다시 만든다.
SET foreign_key_checks = 0으로 검사를 끌 수 있지만 무결성이 깨진 데이터가 남는다 — 반드시 다시 켤 것.
VIEW는 데이터를 복사하지 않는 가상 테이블. 집계·JOIN·UNION이 들어간 뷰는 수정 불가(조회 전용).
클러스터형 인덱스는 테이블당 1개(PK), 보조 인덱스는 여러 개(UNIQUE).
인덱스는 검색을 빠르게 하지만 INSERT·UPDATE·DELETE는 느려진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
구매 테이블에 회원 표에 없는 bbk 행이 남아 있는 상태에서 FK를 ADD하면 어떻게 되나?
FK 추가가 거부됩니다(ERROR 1452, a foreign key constraint fails). 외래키는 "앞으로 넣을 데이터"뿐 아니라 이미 들어 있는 데이터도 규칙을 지켜야 붙습니다. 그래서 수업에서도 DELETE FROM buytbl WHERE userid = 'bbk';로 규칙을 어긴 행을 먼저 정리한 뒤 FK를 붙였습니다.
usertbl3에서 ORDER BY 없이 조회했더니 정렬돼서 나왔다. 앞으로도 ORDER BY를 생략해도 될까?
생략하면 안 됩니다. 지금은 클러스터형 인덱스(PK) 순서대로 읽는 게 DB에게 편해서 그렇게 나온 것일 뿐, SQL 표준은 ORDER BY 없는 결과의 순서를 약속하지 않습니다. 다른 인덱스를 타거나 조인이 끼거나 DB 버전이 바뀌면 순서가 달라질 수 있습니다. 순서가 의미 있으면 항상 ORDER BY를 명시하세요.
💼 실무·코딩테스트에서는실무에서는 운영 데이터에 제약조건을 뒤늦게 붙이다가 기존 데이터가 규칙을 어겨 실패하는 일이 흔해서, 먼저 위반 행을 조회해 정리한 뒤 붙입니다. ON DELETE CASCADE는 편리하지만 한 줄 삭제가 수천 줄 연쇄 삭제가 될 수 있어, 회원 탈퇴처럼 중요한 데이터는 CASCADE 대신 삭제 표시(탈퇴 여부 컬럼)로 처리하는 경우가 많습니다. 면접에서는 "클러스터형 vs 보조 인덱스", "인덱스의 장단점", "뷰를 쓰는 이유(보안·단순화)"가 자주 나옵니다.
한 줄 요약26일차에 배운 인덱스를 EXPLAIN으로 실제 측정해 보고, 인덱스를 만들어도 안 쓰이는 세 경우를 확인했다. 그리고 SQL 안에서 변수·분기·반복을 쓰는 스토어드 프로시저와 함수로 넘어가며 SQL 과정을 마무리했다(다음 날부터는 웹).
쉽게 말하면어제가 "인덱스가 뭔지"였다면 오늘은 "진짜 쓰이고 있는지 확인하는 법"입니다. 인덱스를 걸어 두고 빨라졌겠거니 넘어가는 게 가장 흔한 실수인데, EXPLAIN은 DB에게 "너 이거 어떻게 풀 거야?"라고 미리 물어보는 도구예요. 후반부에는 SQL이 반복문과 조건문을 쓸 수 있는 언어로 잠깐 변신합니다.
① EXPLAIN — 숫자로 확인하는 인덱스 효과
employees 샘플 데이터를 ORDER BY RAND()로 섞어 똑같이 세 벌(인덱스 없음 emp / PK emp_c / 보조 인덱스 emp_se) 만들고 같은 조건으로 조회해 보면, 3만 행으로 측정했을 때 읽는 행 수가 30,014 → 999로 30배 줄어듭니다. type이 ALL(전체 훑기)에서 range(범위 찾기)로 바뀐 것이 그 증거입니다. 인덱스를 만든 뒤에는 ANALYZE TABLE로 통계를 갱신해야 옵티마이저가 제대로 판단합니다. → 🗄️ 42. EXPLAIN
② 인덱스를 만들어도 안 쓰이는 세 경우
① 조회 범위가 넓으면(96% 조회 시 전체 훑기로 전환) ② 컬럼을 연산으로 감싸면(emp_no*1은 못 쓰고 20000/1은 씀) ③ 값의 종류가 적으면(성별은 인덱스가 있어도 무시). 특히 ②번은 WHERE YEAR(날짜)=2020 형태로 실무에서 자주 나오는 함정입니다. USE INDEX·IGNORE INDEX 힌트로 옵티마이저의 선택을 바꿔 보며 비교하는 것도 해 봤습니다. → 🗄️ 43. 인덱스 미사용
③ 프로시저와 함수 — 어디에 쓰느냐가 다르다
프로시저는 CALL로 작업을 시키는 것, 함수는 SELECT 안에서 값을 받아 쓰는 것입니다. 프로시저를 SELECT에서 부르면 ERROR 1305 FUNCTION ... does not exist가 나는데, DB가 그 자리에서는 함수만 찾기 때문입니다. 본문에 세미콜론이 들어가므로 DELIMITER로 문장 끝 기호를 잠시 바꿔 두고 만들어야 합니다. 연습문제 13번 1번은 부서번호를 받아 그 부서 사원을 보여 주되, 없는 부서면 안내 문구를 내는 프로시저였습니다. → 🗄️ 44. 프로시저 · 🗄️ 45. 제어문 · 🗄️ 46. 함수
CREATE TABLE emp SELECT * FROM employees.employees
ORDER BY RAND(); -- ← 인덱스 없음
CREATE TABLE emp_c SELECT * FROM employees.employees
ORDER BY RAND(); -- ← PK(클러스터형) 붙일 표
CREATE TABLE emp_se SELECT * FROM employees.employees
ORDER BY RAND(); -- ← 보조 인덱스 붙일 표
ALTER TABLE emp_c
ADD PRIMARY KEY (emp_no);
ALTER TABLE emp_se
ADD INDEX idx_emp_se_em_no (emp_no);
-- 생성한 인덱스의 실제 적용을 하려면
ANALYZE TABLE emp, emp_c, emp_se; -- ← 통계 갱신
-- 조회해서 성능 확인하기
EXPLAIN SELECT * FROM emp WHERE emp_no < 11000;
EXPLAIN SELECT * FROM emp_c WHERE emp_no < 11000;
EXPLAIN SELECT * FROM emp_se WHERE emp_no < 11000;
-- 쿼리문에 조건을 잘못 만들 경우
EXPLAIN SELECT * FROM emp_c WHERE emp_no = 100000;
EXPLAIN SELECT * FROM emp_c WHERE emp_no*1 = 100000; -- (x) ← 컬럼을 감싸면 인덱스 못 씀
EXPLAIN SELECT * FROM emp_c WHERE emp_no = 100000/1; -- (o) ← 우변 계산은 괜찮음
-- 데이터의 중복도가 높고, 종류가 적을 경우
ALTER TABLE emp
ADD INDEX idx_emp_gender (gender);
EXPLAIN SELECT * FROM emp WHERE gender = 'M'; -- ← M/F 두 종류라 인덱스 포기
실행 결과3만 행으로 측정한 EXPLAIN의 type · rows (🗄️ 42·43번 카드 측정값) 먼저 예측 → 펼쳐서 확인
테이블 / 조건 type key rows
---------------------------------------------------------------
emp emp_no < 11000 ALL (안 씀) 30014 ← 전체를 다 훑는다
emp_c emp_no < 11000 range PRIMARY 999
emp_se emp_no < 11000 range idx_emp_se_emp_no 999
emp_se emp_no < 39000 (96%) ALL (안 씀) 30014 ← 범위가 넓으면 포기
emp_c emp_no = 20000 const PRIMARY 1
emp_c emp_no*1 = 20000 ALL (안 씀) 29970 ← 좌변 연산
emp_c emp_no = 20000/1 const PRIMARY 1
emp gender = 'M' ALL (안 씀) 30014 ← 값 2종류
측정은 42·43번 카드에서 같은 구조로 다시 해 본 값이라 조건 숫자(20000, 39000)가 수업 파일(100000, 400000)과 조금 다릅니다 — 확인하려는 현상은 같습니다. emp_no*1 = 20000과 emp_no = 20000/1은 수학적으로 같은 뜻인데 결과가 정반대입니다. 인덱스는 emp_no값 그대로 정렬돼 있어서, 좌변을 건드리면 그 정렬을 쓸 수 없게 됩니다.
SQL쿼리 #1.sql (수업 실습 원본 — 스토어드 함수 · 연습문제 13 문제 1)
-- 스토어드 함수 사용하기
delimiter $$ -- ← 문장 끝 기호를 잠시 $$로
CREATE FUNCTION userfunc(VALUE1 INT, VALUE2 INT)
RETURNS INT -- ← 선언: 무엇을 돌려줄지 (s 있음)
begin
RETURN VALUE1+VALUE2; -- ← 실행: 실제로 돌려줌 (s 없음)
END $$
delimiter ;
SELECT userFunc(100, 200); -- ← 함수는 SELECT 안에서 값처럼
SELECT userFunc(100, 200) INTO @age;
SELECT @age;
-- 문제 1 — 특정 부서의 사원 목록 조회
DROP PROCEDURE IF EXISTS getDept_emp;
delimiter $$
CREATE PROCEDURE getDept_emp(IN p_deptno INT)
BEGIN
-- 부서 존재 여부를 체크할 변수 선언
DECLARE v_cnt INT DEFAULT 0; -- ← DECLARE는 BEGIN 바로 뒤에만
-- dept 테이블에 전달받은 부서번호가 있는지 확인
SELECT COUNT(*) INTO v_cnt -- ← 조회 결과를 변수에 대입
FROM dept
WHERE deptno = p_deptno;
-- 조건 분기
IF v_cnt > 0 THEN
SELECT ename, job, sal
FROM emp
WHERE deptno = p_deptno;
ELSE
SELECT '해당 부서가 없습니다.' AS '메시지';
END IF;
END $$
delimiter ;
실행 결과함수 호출과 CALL getDept_emp(10) · (99) 먼저 예측 → 펼쳐서 확인
SELECT userFunc(100, 200); → 300
SELECT @age; → 300
CALL getDept_emp(10);
+--------+-----------+------+
| ename | job | sal |
+--------+-----------+------+
| CLARK | MANAGER | 2450 |
| KING | PRESIDENT | 5000 |
| MILLER | CLERK | 1300 |
+--------+-----------+------+
CALL getDept_emp(99);
+-----------------------+
| 메시지 |
+-----------------------+
| 해당 부서가 없습니다. |
+-----------------------+
10번 부서는 dept 표에 있으니 v_cnt가 1이 되어 사원 목록이, 99번은 0이라 ELSE의 안내 문구가 나옵니다. DELIMITER가 왜 필요한지가 이 실습의 핵심입니다 — 본문 안에도 세미콜론이 들어가는데, 문장을 세미콜론 기준으로 잘라 서버에 보내는 것은 클라이언트(HeidiSQL)라서, 그대로 두면 첫 세미콜론에서 잘린 반쪽 프로시저가 전송되어 ERROR 1064가 납니다.
27일차에 배운 것 — 핵심 정리
EXPLAIN에서 볼 것은 type(ALL이면 전체 훑기)과 rows(읽을 행 수), 그리고 key가 NULL인지.
ANALYZE TABLE로 통계를 갱신해야 옵티마이저가 제대로 판단한다.
인덱스 미사용 3대 원인: 넓은 범위 · 컬럼을 감싼 연산 · 낮은 카디널리티.
컬럼은 왼쪽에 맨몸으로 두어야 인덱스를 탄다. YEAR(d)=2020 대신 범위 조건으로.
USE INDEX·IGNORE INDEX 힌트는 진단용으로 쓴다.
DELIMITER를 빼면 ERROR 1064 — 본문의 세미콜론에서 잘리기 때문.
DECLARE는 BEGIN 바로 뒤에서만. 대입은 SET 또는 SELECT ... INTO.
SELECT 안의 CASE(값을 만드는 식)와 프로시저의 CASE(흐름을 가르는 문장, END CASE)는 다른 것이다.
RETURNS는 선언, RETURN은 실행 — s 하나 차이로 자주 틀린다.
hr 데이터로 3중 조인 + GROUP BY + HAVING 연습문제도 함께 풀었다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
hire_date에 인덱스가 있는데 WHERE YEAR(hire_date) = 2020이 느리다. 어떻게 고치나?
YEAR(hire_date)처럼 컬럼을 함수로 감싸면 인덱스에 정렬된 hire_date 값을 그대로 쓸 수 없어 전체를 훑게 됩니다(emp_no*1과 같은 원리). WHERE hire_date >= '2020-01-01' AND hire_date < '2021-01-01'처럼 컬럼은 맨몸으로 두고 범위 조건으로 바꾸면 인덱스를 탑니다.
getDept_emp를 SELECT getDept_emp(10);으로 부르면 어떻게 되나?
ERROR 1305 FUNCTION ... getDept_emp does not exist가 납니다. SELECT 안의 이름은 값을 돌려주는 함수로만 찾기 때문에, 분명히 만든 프로시저인데도 "없다"고 합니다. 프로시저는 CALL getDept_emp(10);으로 불러야 합니다.
💼 실무·코딩테스트에서는실무에서 "쿼리가 느리다"는 문제를 받으면 제일 먼저 EXPLAIN으로 type·key·rows를 확인합니다 — 추측으로 인덱스를 추가하지 않습니다. 면접에서는 "인덱스가 안 타는 경우"와 "카디널리티"가 단골 질문입니다. 스토어드 프로시저는 회사마다 쓰는 정도가 크게 달라서, 요즘 자바 웹 개발에서는 로직을 애플리케이션(자바) 쪽에 두고 SQL은 MyBatis 매퍼에 두는 방식이 더 흔합니다 — 이 과정도 다음 날부터 그 방향(JDBC → MyBatis)으로 이어집니다.
2026-09-10 · 웹 개발 시작 — Eclipse JEE · Tomcat · JDBC 연동
Eclipse JEETomcat 10.1Dynamic Web ProjectDTO · DAOJDBC 6단계PreparedStatement
한 줄 요약SQL 과정을 마치고 웹 애플리케이션 개발로 넘어왔다. Eclipse JEE와 Tomcat 10.1을 설치해 서버를 붙이고, 다이나믹 웹 프로젝트(01_userboard_sepa)를 만들어 DTO · DAO로 계층을 나눠 JDBC 6단계로 MariaDB에 연결하고, 회원 목록 조회(getAllUser)와 등록(insertUser)까지 만들었다.
쉽게 말하면지금까지는 자바(로직)와 SQL(데이터)을 따로 배웠는데, 오늘부터 이 둘을 이어 붙입니다. 그 사이를 잇는 것이 JDBC이고, 그 결과를 브라우저로 보여 줄 무대가 Tomcat이에요. 오늘은 아직 화면(JSP)까지는 안 가고, "자바 코드가 DB에서 데이터를 꺼내 오는 것"까지 확인했습니다.
① 환경 구성 — 버전이 서로 물려 있다
웹 애플리케이션은 스스로 실행되지 않고 WAS(Tomcat)가 대신 실행해 줍니다. 그런데 Tomcat 버전이 Servlet·JSP 스펙과 JDK 범위를 정하기 때문에 아무 버전이나 섞을 수 없습니다. 이 과정은 Tomcat 10.1 + JDK 21 + Servlet 6.0 조합이고, Tomcat 10부터 패키지가 javax.*에서 jakarta.*로 바뀐 점이 특히 중요합니다. → 🖥️ 01. 개발 환경
② 다이나믹 웹 프로젝트 구조 — WEB-INF는 숨겨진 방
자바 소스는 src/main/java에, 화면 파일(JSP)은 src/main/webapp에 둡니다. webapp/WEB-INF 안은 브라우저가 주소로 직접 열 수 없는 자리라 설정 파일(web.xml)과 라이브러리를 숨겨 두는 곳이고, MariaDB 드라이버 jar를 WEB-INF/lib에 넣으면 자동으로 클래스패스에 들어갑니다. → 🖥️ 02. 프로젝트 구조
③ DTO와 DAO — 역할을 나눠 만든 첫 프로젝트
01_userboard_sepa의 sepa는 separation(분리)입니다. 회원 한 명(테이블 한 행)을 담아 나르는 DTO(userDto), DB 접근을 전담하는 DAO(UserDao), 시켜 보고 결과를 확인하는 main(UserMain)을 각각 다른 패키지(dto·dao·main)로 나눴습니다. UserMain은 SQL도 커넥션도 모른 채dao.getAllUser()만 부릅니다. 테이블은 SQL 시간에 만든 회원 표(usertbl)와 같은 구조로, 접속 DB 이름은 hk입니다. → 🖥️ 03. DTO · 🖥️ 04. DAO · 🖥️ 08. 계층 분리
④ JDBC 6단계 — 자바와 DB를 잇는 정해진 순서
드라이버 로딩 → 연결 → 쿼리 준비 → 실행 → 결과 받기 → 닫기. 단계마다 System.out.println으로 표시해 두면 실패했을 때 몇 단계에서 막혔는지 바로 보입니다. 등록(insertUser)에서는 값을 SQL에 이어 붙이지 않고 ? 자리에 바인딩해 SQL 인젝션을 막고, 조회는 executeQuery(), 등록은 executeUpdate()로 실행합니다. 마지막 닫기는 finally(목록 조회) 또는 try-with-resources(등록)로 반드시 합니다. → 🖥️ 05. JDBC 6단계 · 🖥️ 06. PreparedStatement · 🖥️ 07. 자원 반납
JAVAUserDao.java — 생성자(1단계)와 getAllUser()(2~6단계) (수업 실습 원본, 주석 일부 생략)
public class UserDao {
// [ 1단계 : 드라이버 로딩 ] — new UserDao() 할 때 딱 1번
public UserDao() {
try {
Class.forName("org.mariadb.jdbc.Driver"); // ← 드라이버 클래스를 이름으로 찾아 메모리에
System.out.println("1단계: 드라이버 로딩 성공 (MariaDB 통역사 준비 완료)");
} catch (ClassNotFoundException e) {
System.out.println("1단계: 드라이버 로딩 실패 (라이브러리 jar 파일이 누락되었는지 확인하세요)");
e.printStackTrace();
}
}
public List<userDto> getAllUser() {
List<userDto> list = new ArrayList<>(); // ← 여러 명이니 List로 돌려준다
String url = "jdbc:mariadb://localhost:3306/hk"; // ← jdbc:mariadb://호스트:포트/DB명
String user = System.getenv().getOrDefault("STUDY_DB_USER", "study");
// 공개 저장소에 올리면서 실제 값을 뺐다. 내 환경의 비밀번호를 넣고 쓸 것.
String password = System.getenv("STUDY_DB_PASSWORD");
String sql = " SELECT "
+ " USERID, NAME, BIRTHYEAR, ADDR, MOBILE1, MOBILE2, HEIGHT, "
+ " MDATE FROM USERTBL "
+ " ORDER BY MDATE DESC ";
Connection conn = null;
PreparedStatement psmt = null;
ResultSet rs = null;
try {
conn = DriverManager.getConnection(url, user, password); // ← 2단계: 연결
System.out.println("2단계: DB연결 성공");
psmt = conn.prepareStatement(sql); // ← 3단계: 쿼리 준비
System.out.println("3단계: 쿼리준비 성공");
rs = psmt.executeQuery(); // ← 4단계: 조회는 executeQuery()
System.out.println("4단계: 쿼리실행 성공");
while (rs.next()) { // ← 5단계: 한 줄씩 내려가며, 있으면 true
userDto dto = new userDto(); // ← 한 행마다 새 상자
dto.setUserId(rs.getString(1)); // ← 컬럼 번호는 1부터
dto.setName(rs.getString(2));
dto.setBirthYear(rs.getInt(3));
// ... 4~8번째 열도 같은 방식
dto.setmDate(rs.getDate(8));
list.add(dto);
}
System.out.println("5단계: 쿼리결과 받기 성공 (총 " + list.size() + "명 포장 완료)");
} catch (SQLException e) {
System.out.println("SQL 실행 중 문제가 발생했습니다.");
e.printStackTrace();
} finally {
// [ 6단계 : 자원 닫기 ] 열었던 순서의 반대로: rs -> psmt -> conn
try {
if (rs != null) rs.close();
if (psmt != null) psmt.close();
if (conn != null) conn.close();
System.out.println("6단계: DB 연결 안전하게 닫기 완료");
} catch (SQLException e) {
e.printStackTrace();
}
}
return list;
}
JAVAUserDao.insertUser() + UserMain.java (수업 실습 원본, 주석 일부 생략)
public boolean insertUser(userDto dto) {
int count = 0;
// ... url · user · password 는 위와 같음
// ?(물음표)는 나중에 자바 변수를 안전하게 꽂아 넣을 "빈칸 홀더"
String sql = " INSERT INTO USERTBL VALUES(?,?,?,?,?,?,?,SYSDATE()) "; // ← 가입일은 DB 시각
// try-with-resources: 괄호 안 자원은 끝나면 자동으로 close()
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
psmt.setString(1, dto.getUserId()); // ← 1번째 ?: 번호는 1부터
psmt.setString(2, dto.getName());
psmt.setInt(3, dto.getBirthYear()); // ← 타입에 맞는 setXxx
psmt.setString(4, dto.getAddr());
psmt.setString(5, dto.getMobile1());
psmt.setString(6, dto.getMobile2());
psmt.setInt(7, dto.getHeight());
count = psmt.executeUpdate(); // ← 변경은 executeUpdate(): 바뀐 행 수
} catch (SQLException e) {
System.out.println("회원 등록 실패 (아이디 중복 등)");
e.printStackTrace();
}
return count > 0 ? true : false; // ← 1행 이상 들어갔으면 성공
}
// ---------------- UserMain.java ----------------
public class UserMain {
public static void main(String[] args) {
getAllListTest();
}
//회원목록 조회 Test Case
static public void getAllListTest() {
UserDao dao = new UserDao(); // ← 여기서 1단계 실행
List<userDto> list = dao.getAllUser(); // ← SQL·커넥션은 몰라도 된다
for(userDto userDto : list) {
System.out.println(userDto.toString());
}
}
}
실행 결과UserMain을 실행해 DB 연결이 정상일 때 콘솔에 찍히는 순서 먼저 예측 → 펼쳐서 확인
1단계: 드라이버 로딩 성공 (MariaDB 통역사 준비 완료)
2단계: DB연결 성공
3단계: 쿼리준비 성공
4단계: 쿼리실행 성공
5단계: 쿼리결과 받기 성공 (총 N명 포장 완료) ← N = hk.usertbl 회원 수
6단계: DB 연결 안전하게 닫기 완료
userDto [userId=…, name=…, birthYear=…, addr=…, mobile1=…, mobile2=…, height=…]
… (회원 수만큼, 가입일 최신순)
출력 문구는 코드에 적힌 그대로라 이 순서로 나옵니다(회원 줄의 내용은 DB에 든 데이터에 따라 다름). 2단계에서 멈추면 접속 주소·계정·DB 기동 문제, 4단계에서 멈추면 SQL 문제입니다. 1단계가 "로딩 실패"면 드라이버 jar가 WEB-INF/lib(또는 빌드 경로)에 없는 것입니다. toString()에 mDate가 빠져 있어서 가입일은 출력되지 않는데, 이 점은 원본 코드 주석에도 적혀 있습니다.
28일차에 배운 것 — 핵심 정리
웹 애플리케이션에는 main()이 없다 — 요청이 오면 WAS가 대신 실행한다(오늘은 테스트용 UserMain으로 DAO만 따로 확인).
입력값이 SQL 문장의 일부로 해석될 수 있습니다. 아이디 칸에 ' OR '1'='1 같은 글자를 넣으면 문장의 의미 자체가 바뀌는 SQL 인젝션이 됩니다. ? 바인딩은 문장 구조를 먼저 확정해 두고 값은 오직 "값"으로만 꽂기 때문에 이런 일이 생기지 않습니다. 따옴표 처리 실수도 사라지는 덤이 있습니다.
getAllUser에서 6단계(close)를 빼먹어도 당장은 잘 돌아간다. 왜 그래도 빼면 안 되나?
닫지 않은 연결이 호출할 때마다 하나씩 쌓이기 때문입니다. DB가 동시에 받을 수 있는 연결 수는 정해져 있어서, 웹에서 요청이 계속 오면 어느 순간 "더 이상 연결할 수 없다"며 전체가 멈춥니다. 당장은 멀쩡하다가 운영 중에 서서히 죽는 버그라, finally나 try-with-resources로 반드시 닫아야 합니다.
UserMain은 왜 Connection이나 SQL을 전혀 모르는데도 회원 목록을 받을 수 있나?
DB와 대화하는 일을 전부 UserDao에 맡기고, 결과는 List<userDto>라는 약속된 모양으로만 받기 때문입니다. 이렇게 나눠 두면 DB 접속 방식이나 SQL이 바뀌어도 DAO만 고치면 되고, 내일부터 붙일 JSP 화면도 dao.getAllUser() 한 줄로 같은 데이터를 쓸 수 있습니다.
💼 실무·코딩테스트에서는실무에서 JDBC를 이렇게 직접 쓰는 일은 드뭅니다 — 이 과정 후반의 MyBatis(그리고 JPA)가 6단계를 대신해 주니까요. 하지만 커넥션 누수, ? 바인딩과 SQL 인젝션, executeQuery/executeUpdate의 차이는 프레임워크를 써도 그대로 적용되는 원리라 면접에서 자주 묻습니다. 또 DB 비밀번호를 소스에 적지 않는 것은 실무의 기본 — 이 수업 파일도 공개 저장소에 올리면서 비밀번호를 환경 변수로 뺐습니다.
한 줄 요약어제 만든 목록 조회·등록에 이어 한 건 조회(getUser) · 수정(updateUser) · 삭제(deleteUser)를 채워 DAO를 완성했다. DTO에는 수정 전용 생성자를 오버로딩으로 하나 더 만들었다.
쉽게 말하면업무 시스템이 하는 일은 결국 넣고 · 보고 · 고치고 · 지우는 네 가지(CRUD)입니다. 오늘 그 네 가지를 한 클래스 안에 다 갖췄어요. 여기까지 오면 화면만 붙이면 되는 상태가 됩니다 — 그게 내일 할 일입니다.
① 한 건 조회는 무엇이 다른가
반환 타입이 List가 아니라 userDto 하나이고, 결과를 훑는 것도 while이 아니라 if입니다. 아이디는 기본키라 많아야 한 줄이기 때문이죠. 그리고 못 찾았을 때 무엇을 돌려줄지를 정해야 하는데, 여기서는 dto를 null로 시작해 찾았을 때만 새 상자를 만들도록 했습니다 — 못 찾으면 null이 돌아갑니다. 이 선택이 내일 화면을 만들 때 NullPointerException으로 돌아옵니다. ResultSet도 닫아야 하는 자원이라 try-with-resources를 한 겹 더(중첩) 썼습니다. → 🖥️ 12. CRUD 완성
② UPDATE의 물음표 순서 · executeUpdate의 반환값
SET addr=?, mobile1=?, mobile2=?, height=? WHERE userid=?에서 WHERE의 물음표가 마지막 5번입니다. 번호는 절의 이름이 아니라 SQL에 나타난 순서로 매겨지기 때문입니다. 여기서 순서를 틀리면 에러 없이 엉뚱한 칸이 바뀝니다. 수정·삭제는 등록처럼 executeUpdate()로 실행하고, 돌아오는 "바뀐 행 수"가 1 이상이면 성공(true)으로 봅니다 — 없는 아이디를 고치면 에러가 아니라 0행이라 false가 됩니다.
③ 수정 전용 생성자 — 오버로딩
수정 화면에서는 아이디·주소·전화 두 칸·키 다섯 개만 넘어옵니다. 8개짜리 생성자를 쓰면 나머지에 null·0을 억지로 채워야 하죠. 그래서 매개변수 5개짜리 생성자를 하나 더 만들었습니다 — 같은 이름, 다른 매개변수. 자바 시간에 배운 생성자 오버로딩이 실제 쓰임새로 돌아온 것입니다. → 🧱 06. 오버로딩
JAVAUserDao.getUser() — 회원 1명 상세 조회 (수업 실습 원본, 주석 일부 생략)
public userDto getUser(String userId) {
// 결과가 없을 수도 있으므로 처음에는 빈손(null)으로 시작
userDto dto = null; // ← 못 찾으면 이 null이 그대로 반환
// ... url · user · password 는 getAllUser와 같음
String sql = " SELECT userid, NAME, birthyear, addr, mobile1, mobile2, height, mdate "
+ " FROM usertbl "
+ " WHERE userid = ? "; // ← PK 조건이라 결과는 0행 또는 1행
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
psmt.setString(1, userId);
try (ResultSet rs = psmt.executeQuery()) { // ← ResultSet도 자동으로 닫히게 중첩
if (rs.next()) { // ← 한 명이니 while 대신 if
dto = new userDto(); // ← 실제로 있을 때만 상자를 만든다
dto.setUserId(rs.getString(1));
dto.setName(rs.getString(2));
// ... 3~8번째 열도 같은 방식
dto.setmDate(rs.getDate(8));
}
}
} catch (Exception e) {
e.printStackTrace();
}
return dto; // ← 찾았으면 상자, 못 찾았으면 null
}
JAVAUserDao.updateUser() · deleteUser() + userDto 5개짜리 생성자 (수업 실습 원본, 주석 일부 생략)
public boolean updateUser(userDto dto) {
int count = 0;
String sql = " UPDATE usertbl "
+ " SET addr = ? , mobile1 = ? , mobile2 = ? , height = ? "
+ " WHERE userid = ? "; // ← WHERE가 없으면 전원이 바뀐다
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
// 물음표 순서(1~5)와 DTO에서 꺼내는 데이터 순서가 반드시 일치해야 합니다.
psmt.setString(1, dto.getAddr());
psmt.setString(2, dto.getMobile1());
psmt.setString(3, dto.getMobile2());
psmt.setInt(4, dto.getHeight());
psmt.setString(5, dto.getUserId()); // ← WHERE의 ?는 마지막 5번
count = psmt.executeUpdate(); // ← 수정 성공 시 1
} catch (Exception e) {
e.printStackTrace();
}
return count > 0 ? true : false;
}
public boolean deleteUser(String userId) {
int count = 0;
String sql = " DELETE FROM usertbl WHERE userid = ? ";
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
psmt.setString(1, userId);
count = psmt.executeUpdate(); // ← 지워진 행 수
} catch (Exception e) {
e.printStackTrace(); // ← 외래키 위반 등도 여기서 잡혀 false
}
return count > 0 ? true : false;
}
// ---------------- userDto.java ----------------
// 수정 화면에서 넘어오는 5가지만 받는 생성자 (오버로딩)
public userDto(String userId, String addr, String mobile1, String mobile2, int height) {
super();
this.userId = userId;
this.addr = addr;
this.mobile1 = mobile1;
this.mobile2 = mobile2;
this.height = height;
}
관찰 포인트코드만 보고 반환값 예측하기 — 경우별로 무엇이 돌아오나
getUser("KBS") → userDto 상자 (KBS의 8개 값이 채워짐)
getUser("NONE") → null ← rs.next()가 false라 상자를 안 만듦
updateUser(KBS…) → executeUpdate() = 1 → true
updateUser(NONE…) → executeUpdate() = 0 → false ← 에러가 아니라 "0행"
deleteUser("KKH") → 1 → true (구매 이력 없는 회원)
deleteUser("KBS") → 외래키 위반 SQLException → catch → count 0 → false
(구매 이력 있는 회원)
실패에도 두 종류가 있다는 게 핵심입니다. 없는 아이디를 고치거나 지우면 SQL은 멀쩡히 실행되고 0행이 바뀔 뿐이고, 구매 이력이 있는 회원을 지우면 DB가 외래키 규칙으로 예외를 던집니다. 지금 DAO는 둘 다 false 하나로 돌려주기 때문에, 화면 쪽에서는 왜 실패했는지 구분할 수 없습니다 — 내일 JSP를 붙여 실제로 확인합니다. (KBS 삭제 거부는 30일차에 직접 돌려 본 결과입니다 → 🖥️ 15. 외래키)
수정·삭제도 executeUpdate() — 돌아오는 바뀐 행 수로 성공 여부를 판단한다.
UPDATE·DELETE에 WHERE를 빠뜨리면 전체 행이 바뀐다.
ResultSet도 자원이라 중첩 try-with-resources로 함께 닫는다.
용도가 다르면 생성자를 오버로딩해 필요한 것만 받는다.
여기까지가 화면 없는 백엔드 — 다음은 JSP로 화면을 붙인다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
updateUser에서 setString(1, dto.getUserId())로 아이디를 1번에 넣고 나머지를 한 칸씩 밀면 어떻게 되나?
에러 없이 엉뚱한 값이 저장되거나 아무 행도 안 바뀝니다. SQL의 1번 ?는 addr 자리라서 주소 칸에 아이디가 들어가려 하고, 5번 ?(WHERE userid)에는 키 같은 다른 값이 들어가 그런 아이디가 없으니 0행이 바뀝니다. 자료형이 맞으면 컴파일도 실행도 통과하므로, SQL에 나타난 순서대로 번호를 다시 세어 보는 습관이 필요합니다.
getUser가 null을 돌려줬는데 화면에서 바로 dto.getName()을 부르면?
NullPointerException이 납니다. null은 "상자가 없다"는 뜻이라 꺼낼 값도 없습니다. 웹에서는 이게 HTTP 500 에러 화면으로 그대로 보이므로, 받는 쪽에서 if (dto == null)로 먼저 확인하고 안내 페이지로 보내야 합니다 — 30일차 userDetail.jsp가 바로 그렇게 처리합니다.
💼 실무·코딩테스트에서는실무 백엔드 코드의 대부분은 결국 CRUD 네 가지의 조합이고, 지금 손으로 쓴 DAO의 모양(메서드 이름 · 반환 타입 · WHERE 기본키)은 MyBatis 매퍼와 Spring의 Repository로 그대로 이어집니다. 실무에서는 "못 찾음"을 null 대신 Optional이나 전용 예외로 표현하기도 하고, 실패를 false 하나로 뭉개지 않고 원인(없음 / 제약조건 위반)을 구분해 화면에 알려 주는 것이 좋은 설계입니다. 면접에서는 "executeQuery와 executeUpdate의 차이", "PreparedStatement를 쓰는 이유"가 자주 나옵니다.
한 줄 요약DAO 위에 JSP로 화면을 붙여 브라우저에서 돌아가는 고객관리 시스템을 완성했다. 목록·상세·수정·삭제·등록을 만들고, 같은 구조를 구매 목록(BuyDto·BuyDao·buy*.jsp)에 한 번 더 적용했다.
쉽게 말하면어제까지는 콘솔에 글자만 찍히는 프로그램이었는데, 오늘 브라우저에서 클릭으로 쓰는 화면이 됐습니다. 그 사이를 잇는 것이 JSP예요 — HTML 안에 자바를 섞어 쓰는 파일이죠. 그리고 같은 구조를 구매 목록으로 한 번 더 만들어 보면서 패턴이 손에 익습니다.
① JSP 3형제 — 설정 · 실행 · 출력
<%@ page import="..." %>는 설정(지시자), <% %>(스크립틀릿)는 자바를 실행만, <%= %>(표현식)는 값을 화면에 출력합니다. userList.jsp는 스크립틀릿에서 dao.getAllUser()로 목록을 받고, for문을 HTML 사이사이에 끊어 넣어 회원 수만큼 <tr>을 찍습니다. 이 모든 자바는 서버(Tomcat)에서 실행되고, 브라우저는 실행이 끝난 HTML만 받습니다. → 🖥️ 09. JSP 3형제
② 페이지 사이로 값 넘기기 — 쿼리스트링과 hidden
목록에서 이름을 누르면 userDetail.jsp?userid=KBS처럼 주소 뒤에 값을 붙여(쿼리스트링) 보내고, 상세 화면의 수정 폼은 화면에 보여 줄 필요 없는 아이디를 hidden 필드에 실어 보냅니다. 받는 쪽은 request.getParameter("이름")으로 꺼내는데, 숫자를 입력했어도 String으로 오므로 키·출생년도는 Integer.parseInt가 필요하고, 이름을 틀리면 에러가 아니라 null이 옵니다. → 🖥️ 10. getParameter · 🖥️ 11. 쿼리스트링과 hidden
③ 화면 없는 '처리 페이지'와 한글
userUpdate.jsp·userDel.jsp·userInsert.jsp는 보여 줄 화면이 없고 일만 하는 페이지입니다. 끝나면 response.sendRedirect()로 다른 주소로 보내거나, 안내를 띄우고 싶으면 alert + location.href를 씁니다. 등록 뒤에 반드시 이동시켜야 새로고침으로 중복 등록되는 것을 막습니다. POST로 온 한글(주소 등)이 깨지지 않게 값을 꺼내기 전에request.setCharacterEncoding("UTF-8")을 부릅니다. → 🖥️ 13. sendRedirect · 🖥️ 14. setCharacterEncoding
④ 구매 목록 — 외래키가 막아서는 순간
같은 CRUD를 BuyDto·BuyDao로 한 번 더 만들었습니다. 다른 점은 식별자가 userId(문자)가 아니라 num(숫자)이라는 것과, buyTbl.userID가 외래키라는 것 — 실제로 돌려 보니 구매 이력이 있는 회원(KBS)은 삭제가 거부되고, 이력이 없는 회원(KKH)만 지워졌습니다. DB가 데이터를 지켜 준 것입니다. 지금처럼 JSP 한 파일이 화면과 처리를 다 맡는 구조를 MVC1이라 부릅니다. → 🖥️ 15. 외래키 · 🖥️ 16. MVC1과 그 한계
JSPuserList.jsp — 회원 목록 (수업 실습 원본, 일부 생략)
<%@page import="com.hk.board.dao.UserDao"%> <%-- ← 지시자: 자바 import --%>
<%@page import="com.hk.board.dto.userDto"%>
<%@page import="java.util.List"%>
<% //JSP 구성요소중 jsp tag: scriptlet(실행부)
UserDao dao = new UserDao();
List<userDto> list = dao.getAllUser(); // ← 어제 만든 DAO를 그대로 사용
%>
<body>
<h1>고객 조회 결과</h1>
<table border = "1">
<tr>
<th>아이디</th><th>이름</th><th>가입일</th><th>삭제</th>
</tr>
<%
for(userDto dto:list){ // ← 자바 반복문을 HTML 사이에 끊어 넣기
%>
<tr>
<td><%=dto.getUserId()%></td> <%-- ← 표현식: 값을 출력, 세미콜론 없음 --%>
<td><a href="userDetail.jsp?userid=<%=dto.getUserId()%>"><%=dto.getName()%></a></td>
<td><%=dto.getmDate()%></td>
<td><a href="#" onClick="delUser('<%=dto.getUserId()%>')">삭제</a></td>
</tr>
<%
}
%>
</table>
<script type="text/javascript">
function delUser(userId) {
if(confirm("정말 삭제하겠습니까?")){ // ← 브라우저에서 한 번 더 확인
location.href="userDel.jsp?userid="+userId; // ← 쿼리스트링으로 아이디 전달
}
}
</script>
JSPuserDetail.jsp · userUpdate.jsp · userDel.jsp — 받고, 처리하고, 이동하기 (수업 실습 원본, 일부 생략)
<%-- ===== userDetail.jsp ===== --%>
<%
String userId = request.getParameter("userid"); // ← ?userid=KBS 의 값
UserDao dao = new UserDao();
userDto dto = dao.getUser(userId); //회원한명에 대한 정보 저장
//getUser는 못 찾으면 null을 돌려준다. 그대로 두면 아래에서 값을 꺼낼 때
//NullPointerException이 나면서 자바 에러 화면(HTTP 500)이 그대로 보인다.
if (dto == null) {
response.sendRedirect("error.jsp");
return; //sendRedirect는 아래 코드를 멈추지 않으므로 return이 꼭 필요하다
}
%>
<form action="userUpdate.jsp" method="post"> <%-- ← 변경은 POST --%>
<input type="hidden" name="userid" value="<%=dto.getUserId()%>"/> <%-- ← 안 보이게 실어 보냄 --%>
<input type="text" name ="addr" value="<%=dto.getAddr() %>"/>
<input type="text" name ="height" value="<%=dto.getHeight() %>"/>
...
<%-- ===== userUpdate.jsp ===== --%>
<%
request.setCharacterEncoding("UTF-8"); // ← 값을 꺼내기 전에! (POST 한글)
String userId = request.getParameter("userid");
String addr = request.getParameter("addr");
String mobile1 = request.getParameter("mobile1");
String mobile2 = request.getParameter("mobile2");
String sHeight = request.getParameter("height"); // ← 숫자를 입력해도 "178" 같은 String
int height = Integer.parseInt(sHeight); // ← 직접 int로 변환
UserDao dao = new UserDao();
boolean isS = dao.updateUser(new userDto(userId, addr, mobile1, mobile2, height)); // ← 5개짜리 생성자
if (isS) {
%>
<script type="text/javascript">
alert("회원정보를 수정했습니다.!!");
location.href="userDetail.jsp?userid=<%=userId%>"; // ← 안내 후 상세 화면으로
</script>
<%
} else{
response.sendRedirect("error.jsp");
}
%>
<%-- ===== userDel.jsp ===== --%>
<%
String userId=request.getParameter("userid");
UserDao dao = new UserDao();
boolean isS = dao.deleteUser(userId);
if(isS){
response.sendRedirect("userList.jsp"); // ← 성공: 목록으로 (브라우저가 다시 요청)
}else{
response.sendRedirect("error.jsp"); // ← 실패: 에러 화면으로
}
%>
실행 결과구매 이력이 있는 회원과 없는 회원을 각각 삭제해 본 결과 (🖥️ 15번 카드에서 확인) 먼저 예측 → 펼쳐서 확인
[1] 구매 이력이 있는 회원 삭제 — userDel.jsp?userid=KBS
HTTP 302 → Location: error.jsp ← 실패, 에러 화면으로
(콘솔에 찍힌 진짜 이유)
Cannot delete or update a parent row: a foreign key constraint fails
(`hk`.`buytbl`, CONSTRAINT `1` FOREIGN KEY (`userID`)
REFERENCES `usertbl` (`userID`))
[2] 구매 이력이 없는 회원 삭제 — userDel.jsp?userid=KKH
HTTP 302 → Location: userList.jsp ← 성공, 목록으로
외래키가 데이터를 지켜 주고 있습니다. 구매 기록이 남아 있는 회원을 지우면 주인 없는 구매 내역이 되니까 DB가 아예 거부했고, deleteUser의 catch가 예외를 잡아 false를 돌려주자 userDel.jsp가 error.jsp로 보냈습니다. 다만 사용자가 보는 화면은 "시스템 오류"뿐이라 왜 실패했는지 알 수 없습니다 — 29일차에 짚은 "실패를 false 하나로 뭉갠" 설계의 한계가 화면에서 드러난 것입니다. HTTP 302는 sendRedirect가 브라우저에게 "이 주소로 다시 가라"고 응답한 표시입니다.
30일차에 배운 것 — 핵심 정리
<% %>는 실행만, <%= %>는 출력 — 등호 하나 차이로 하는 일이 다르다. <%= %> 안에는 세미콜론을 쓰지 않는다.
브라우저가 받는 것은 실행이 끝난 HTML뿐 — 자바 코드는 한 줄도 안 간다.
request.getParameter는 언제나 String을 돌려준다 — 숫자는 직접 변환. 이름이 틀리면 에러가 아니라 null이 온다.
페이지 사이 값 전달: 쿼리스트링(?이름=값)과 hidden 필드. hidden은 숨김이 아니다 — 소스 보기로 다 보이고 고쳐 보낼 수도 있다.
조회는 GET, 변경은 POST가 원칙이다.
처리 뒤에는 반드시 다른 주소로 이동(PRG) — 새로고침 중복 등록 방지. sendRedirect 뒤에 코드가 남아 있으면 return으로 멈춘다.
POST 한글은 request.setCharacterEncoding("UTF-8")을 값 꺼내기 전에.
외래키가 걸린 부모 행은 자식이 남아 있으면 삭제되지 않는다.
지금 구조의 이름은 MVC1 — 다음 진도는 서블릿으로 흐름을 분리하는 MVC2.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
수정 폼에서 키 칸을 비워 두고 '회원수정'을 누르면 userUpdate.jsp에서 무슨 일이 생기나?
빈 칸은 null이 아니라 빈 문자열 ""로 넘어오고, Integer.parseInt("")가 NumberFormatException을 던져 HTTP 500 에러 화면이 뜹니다. 화면에서 required로 막거나, 서버에서 변환 전에 빈 값을 확인하고 안내 페이지로 보내야 합니다 — 15일차에 배운 예외 처리가 웹에서도 그대로 필요한 자리입니다.
userDetail.jsp에서 sendRedirect("error.jsp") 뒤의 return;을 지우면 어떻게 되나?
sendRedirect는 "이 주소로 다시 가라"는 응답을 예약만 할 뿐, 그 줄에서 JSP 실행을 멈추지 않습니다. 그래서 아래로 계속 내려가 dto.getUserId()를 부르는 순간 dto가 null이라 NullPointerException이 납니다. 이동을 결정했으면 return으로 그 자리에서 끝내야 합니다.
회원 등록 처리 뒤에 결과 화면을 그대로 보여 주지 않고 꼭 다른 주소로 이동시키는 이유는?
등록 결과를 같은 주소에서 보여 주면, 사용자가 새로고침(F5)을 누를 때 브라우저가 마지막 POST 요청을 다시 보내 같은 회원이 또 등록되려 합니다. sendRedirect나 location.href로 목록 같은 GET 주소로 옮겨 두면 새로고침해도 목록을 다시 조회할 뿐입니다. 이 패턴을 PRG(Post → Redirect → Get)라고 부릅니다.
💼 실무·코딩테스트에서는JSP 안에 자바(스크립틀릿)를 섞는 MVC1 방식은 요즘 실무에서는 거의 쓰지 않습니다 — 화면과 처리가 한 파일에 엉키면 고치기 어렵기 때문이고, 그래서 이 과정도 다음 날부터 서블릿(MVC2) → Spring MVC로 넘어갑니다. 하지만 파라미터는 전부 문자열, GET/POST 구분, PRG, hidden 값은 조작될 수 있다(서버에서 다시 검증)는 원리는 어떤 프레임워크를 써도 그대로입니다. 면접에서는 "forward와 redirect의 차이"가 단골인데, sendRedirect는 브라우저가 새 주소로 다시 요청하는 쪽(그래서 request가 새로 만들어짐)이라는 점을 오늘 코드로 기억해 두세요.
한 줄 요약두 번째 프로젝트 02_hkboard_MVC1을 시작했다. 여러 DAO가 반복하던 접속 코드를 DataBase 부모 클래스로 상속해 없앴고, 요청마다 파일을 나누던 방식 대신 command 파라미터로 분기하는 컨트롤러(boardController.jsp)가 처음 등장했다. 컨트롤러가 화면에 값을 넘기는 방법으로 request.setAttribute + forward를 배웠다.
쉽게 말하면어제 만든 회원·구매 관리에서 불편했던 두 가지를 오늘 바로 고쳤습니다. 비밀번호 하나 바꾸려고 파일 10곳을 뒤졌던 것은 부모 클래스로 한 곳에 모았고, 기능마다 파일을 새로 만들던 것은 안내 데스크 하나(컨트롤러)가 command라는 번호표를 보고 "목록 창구로 가세요"라고 안내하는 방식으로 바뀌었습니다. 둘 다 "반복을 어떻게 줄일까"라는 같은 고민의 답입니다.
① DataBase — 접속 코드를 부모 클래스 한 곳에
DataBase는 JDBC 6단계 중 1단계(드라이버 로딩)와 2단계(연결)만 전담하는 부모 클래스입니다. 1단계는 생성자에, 2단계는 getConnection() 메서드에 들어 있습니다. HkDao extends DataBase로 물려받으면 DAO는 getConnection() 한 줄만 부르면 되고, new HkDao()를 하는 순간 부모 생성자가 먼저 실행되어 드라이버가 로딩됩니다(자식 생성자의 첫 줄에 super()가 자동으로 들어가기 때문 — 자바 상속 복습). 덕분에 DB 접속 정보를 고칠 곳이 어제 10곳에서 오늘 1곳으로 줄었습니다. → 🖥️ 17. DAO 상속
② boardController.jsp — command 하나로 갈래를 나누는 첫 컨트롤러
01 프로젝트는 파일 하나 = 기능 하나(userList.jsp, userDel.jsp …)였습니다. 오늘은 boardController.jsp하나가 모든 요청을 받고, 주소에 붙은 ?command=boardlist 값을 보고 무엇을 할지 고릅니다. 수업 주석이 이 흐름을 7단계로 정리해 두었습니다 — ① command 받기 → ② DAO 만들기 → ③ 요청 분기 → ④ 파라미터 받기 → ⑤ DAO 메서드 실행 → ⑥ Scope 객체에 담기 → ⑦ 페이지 이동. 앞으로 MVC2(서블릿)·스프링으로 넘어가도 이 7단계 뼈대는 그대로이고, 누가 대신해 주느냐만 바뀝니다. → 🖥️ 18. 컨트롤러의 시작
③ forward와 request scope — 같은 요청 안에서 값을 건네기
컨트롤러가 DB에서 꺼낸 list를 화면(boardlist.jsp)에 넘겨야 합니다. 01에서 쓰던 response.sendRedirect는 브라우저에게 "저 주소로 다시 요청하세요"라고 시키는 것이라 새 요청이 되고, 앞 요청에 담아 둔 값이 사라집니다. 그래서 오늘은 request.setAttribute("list", list)로 요청 객체에 담고pageContext.forward("boardlist.jsp")로 서버 안에서 같은 요청을 넘깁니다. 받는 쪽은 request.getAttribute("list")로 꺼내는데, 돌려받는 타입이 Object라서 (List<HkDto>)로 형변환해야 합니다. forward는 서버 안에서만 넘어가므로 주소창은 boardController.jsp?command=boardlist 그대로입니다. → 🖥️ 21. 네 가지 scope · 🖥️ 22. 파라미터와 객체 전달
④ 아직 진행 중인 프로젝트
9월 15일 기준으로 command가 boardinsertform·boardinsert일 때의 분기는 주석만 있고 코드는 없었습니다 — 다음 시간(32일차)에 채웠습니다. 정리하면서 고친 것은 하나뿐입니다: command 없이 이 파일을 열면(주소 오타 등) command가 null이라 equalsIgnoreCase에서 NullPointerException(500)이 났기 때문에, null이면 빈 문자열로 바꿔 아무 분기에도 걸리지 않고 조용히 지나가게 막아 두었습니다.
package com.hk.board.datasource;
// JDBC 1단계와 2단계(DB 연결 통로 만들기)를 전담하는 부모 클래스
public class DataBase {
// [1단계: DB 드라이버 로딩] 객체가 생성(new)될 때 가장 먼저 실행
public DataBase() {
try {
Class.forName("org.mariadb.jdbc.Driver");
System.out.println("1단계: 드라이버 로딩 성공 (MariaDB 통역사 준비 완료)");
} catch (ClassNotFoundException e) {
System.out.println("1단계: 드라이버 로딩 실패 (…)");
e.printStackTrace();
}
}
// [2단계: DB와의 연결 통로(Connection) 개설]
public Connection getConnection() throws SQLException {
Connection conn = null;
String url = "jdbc:mariadb://localhost:3306/hk";
String user = System.getenv().getOrDefault("STUDY_DB_USER", "study");
String password = System.getenv("STUDY_DB_PASSWORD"); // ← 접속 정보를 고칠 곳이 여기 한 군데
conn = DriverManager.getConnection(url, user, password);
return conn; // 연결된 통로 객체를 호출한 곳(DAO)으로 전달
}
}
// ----- HkDao.java -----
public class HkDao extends DataBase { // ← 부모의 getConnection()을 물려받는다
public List<HkDto> getAllList(){
List<HkDto> list = new ArrayList<>();
String sql = "SELECT SEQ, ID, TITLE, CONTENT, REGDATE FROM HKBOARD ORDER BY REGDATE DESC ";
try(Connection conn = getConnection(); // ← url·user·password를 몰라도 된다
PreparedStatement psmt = conn.prepareStatement(sql);
){
try(ResultSet rs = psmt.executeQuery()){
while(rs.next()) {
// … 한 행을 HkDto에 담아 list.add(dto) (JDBC 4·5단계는 01과 같다)
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
}
JSPboardController.jsp — 9월 15일에 있던 부분 (목록 분기까지)
<%
//1단계: command값 받기 -> 어떤 요청인지 확인하기 위한 값을 받는다
String command = request.getParameter("command");
//command 없이 이 파일을 직접 열면 null 이라 아래 equalsIgnoreCase 에서
//NullPointerException 이 난다. 빈 문자열로 바꿔 조용히 넘어가게 한다.
if (command == null) command = ""; // ← 정리하며 추가한 방어 코드
//2단계: DAO 객체 생성
HkDao dao = new HkDao(); // ← 이 순간 부모 생성자(드라이버 로딩)가 먼저 실행
//3단계: 요청분기(요청확인하기)
if(command.equalsIgnoreCase("boardlist")){//글목록요청확인
//4단계: 파라미터 받기 생략
//5단계: dao메서드 실행
List<HkDto>list=dao.getAllList();
//6단계:Scope객체에 담기
request.setAttribute("list", list); // ← 같은 요청 안에서만 살아 있는 보관함
//7단계: 페이지 이동
pageContext.forward("boardlist.jsp"); // ← 서버 안에서 넘김: 주소창은 그대로
}
// (9/15에는 boardinsertform · boardinsert 분기가 주석만 있었다 — 32일차에 채움)
%>
<%-- ===== boardlist.jsp — 넘겨받은 list 꺼내기 ===== --%>
<%
List<HkDto> list =(List<HkDto>)request.getAttribute("list");//저장된 객체의 타입은 Object임
%>
<% for(HkDto dto:list){ %>
<tr>
<td><%=dto.getSeq()%></td>
<td><%=dto.getId()%></td>
<td><%=dto.getTitle()%></td>
<td><%=dto.getRegDate()%></td>
</tr>
<% } %>
동작 설명index.jsp의 "게시판목록" 링크를 눌렀을 때 먼저 예측 → 펼쳐서 확인
[브라우저] GET boardController.jsp?command=boardlist
[서버] boardController.jsp 실행
command = "boardlist"
new HkDao() → 부모 DataBase() 생성자: "1단계: 드라이버 로딩 성공 ..."
dao.getAllList() → List<HkDto>
request.setAttribute("list", list)
pageContext.forward("boardlist.jsp") ← 같은 요청이 boardlist.jsp로 이어짐
boardlist.jsp 실행 → request.getAttribute("list")로 꺼내 표 그리기
[브라우저] 화면은 글목록 표, 주소창은 여전히 boardController.jsp?command=boardlist
요청은 한 번뿐이고 두 JSP가 그 요청 하나를 이어서 처리합니다. 그래서 request에 담은 list가 boardlist.jsp까지 살아 있고, 주소창에는 처음 요청한 컨트롤러 주소가 남습니다.
31일차에 배운 것 — 핵심 정리
공통 코드는 부모 클래스로 빼고 자식이 extends로 물려받는다 — 접속 정보를 고칠 곳이 1곳이 된다.
자식 객체를 만들면 부모의 생성자가 자동으로 먼저 실행된다(그래서 new HkDao()만으로 드라이버가 로딩된다).
command 파라미터 하나로 여러 기능을 한 파일에서 분기하는 것이 프론트 컨트롤러 패턴의 시작이다.
컨트롤러의 흐름은 command 받기 → DAO → 분기 → 파라미터 → DAO 실행 → Scope에 담기 → 이동의 7단계다.
forward는 주소창이 안 바뀌고 같은 요청이 이어진다 — sendRedirect는 새 요청이라 주소가 바뀌고 request 값이 사라진다.
request.setAttribute/getAttribute로 forward되는 동안 값을 주고받는다(request scope). getAttribute는 Object를 돌려주므로 원래 타입으로 형변환해야 한다.
수업 코드가 미완성인 채로 진행 중인 것은 자연스럽다 — 다음 시간에 이어서 채운다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
HkDao에는 생성자를 하나도 안 썼는데, new HkDao()를 하면 왜 "1단계: 드라이버 로딩 성공"이 찍힐까?
생성자를 안 쓰면 컴파일러가 기본 생성자를 만들어 주고, 그 첫 줄에 super()(부모 생성자 호출)를 자동으로 넣기 때문입니다. 그래서 HkDao를 만들 때마다 DataBase() 생성자가 먼저 실행되어 Class.forName(...)과 출력문이 돌아갑니다.
주소창에 boardlist.jsp를 직접 쳐서 열면 어떻게 될까?
HTTP 500(NullPointerException)이 납니다. 컨트롤러를 거치지 않았으니 request에 "list"가 담긴 적이 없고, getAttribute("list")가 null을 돌려줍니다. 그 null을 for(HkDto dto : list)로 돌리려는 순간 터집니다. 그래서 화면은 반드시 컨트롤러를 거쳐서 열어야 합니다 — MVC2에서 JSP를 WEB-INF 안에 숨기는 이유이기도 합니다.
forward 대신 response.sendRedirect("boardlist.jsp")로 바꾸면 목록이 나올까?
안 나옵니다. redirect는 브라우저가 boardlist.jsp를 새로 요청하게 만들고, 새 요청의 request는 빈 상태라 list가 없습니다(바로 위 질문과 같은 500). request에 담은 값을 넘기려면 forward여야 합니다.
💼 실무·코딩테스트에서는실무에서는 DataBase 같은 부모 클래스 대신 커넥션 풀(DataSource)이 연결을 맡습니다 — 37일차에 스프링 설정으로 바뀌는 그 부분입니다. 하지만 "공통은 한 곳에, 각자는 자기 일만"이라는 생각은 그대로예요. command 분기 컨트롤러는 스프링의 DispatcherServlet(프론트 컨트롤러)의 원형이고, "forward와 redirect의 차이"는 신입 면접 단골 질문입니다 — "forward는 서버 안에서 넘겨 request가 유지되고 주소가 안 바뀐다, redirect는 브라우저가 새로 요청해 주소가 바뀌고 request가 새로 만들어진다"까지 말할 수 있으면 됩니다.
게시판 CRUDgetBoard · updateBoardredirect vs forward전체 선택scope
한 줄 요약31일차에 목록만 있던 MVC1 게시판에 등록·상세·수정·단일 삭제를 연결하고, 목록에 체크박스 전체 선택을 달았다. 모든 요청은 boardController.jsp가 command로 "무슨 업무인지", seq로 "몇 번 글인지"를 골라 처리한다. 오전에는 JSP 실행 과정·scope·Web Server와 WAS 같은 강조 개념 6가지를 정리했다. 다중 삭제는 아직이다.
쉽게 말하면게시판의 글 쓰기·읽기·고치기·지우기 버튼을 하나씩 실제 배선에 연결한 날입니다. 안내 데스크(컨트롤러)는 그대로이고, 데스크가 알아듣는 번호표 종류(command)가 늘었습니다. 체크박스 전체 선택 스위치도 달았지만, 여러 개를 한꺼번에 지우는 기능은 다음 날 몫입니다.
① 등록 — 저장한 뒤엔 목록 "컨트롤러"로 redirect
글쓰기 폼은 숨은 입력칸 command=boardInsert와 id·title·content를 POST로 보냅니다. 컨트롤러는 getParameter로 세 값을 읽어 new HkDto(id, title, content)(31일차에 만든 글추가용 생성자)에 담아 dao.insertBoard()에 넘깁니다. 성공하면 boardlist.jsp가 아니라 boardController.jsp?command=boardlist로 redirect합니다 — 목록 화면은 list가 있어야 그려지는데, list를 만드는 건 컨트롤러의 목록 분기이기 때문입니다. 또 저장 후 redirect를 하면 주소창이 목록 주소로 바뀌어, 새로고침해도 같은 글이 다시 저장되지 않습니다. → 🖥️ 25. 게시판 CRUD 연결 · 🖥️ 23. 화면 이동
② 상세·수정·단일 삭제 — seq 하나가 모든 걸 잇는다
상세: 목록 제목 링크가 command=boardDetail&seq=번호를 보내면, Integer.parseInt로 숫자로 바꿔 dao.getBoard(seq) → request.setAttribute("dto", dto) → forward합니다(조회는 forward, 31일차와 같은 원리). 수정: 상세 화면 자체가 수정 폼이라, 숨은 입력칸으로 seq를 다시 실어 보내고UPDATE HKBOARD SET TITLE=?, CONTENT=? WHERE SEQ=?을 실행합니다. 물음표 순서대로 값을 넣고, executeUpdate()가 돌려준 영향 받은 행 수가 0보다 큰지로 성공을 판단합니다. 단일 삭제: 상세의 삭제 버튼이 confirm() 후 command=boardDelete&seq=번호로 이동해 DELETE FROM HKBOARD WHERE SEQ = ?를 실행하고 목록 컨트롤러로 redirect합니다. → 🖥️ 22. 파라미터와 객체 전달
③ 전체 선택 — 같은 name의 체크박스를 한꺼번에
목록의 행마다 <input type="checkbox" name="seq" value="글번호">를 두고, 머리줄 체크박스의 onclick="allSel(this.checked)"가 자기 체크 상태(true/false)를 넘깁니다. allSel은 document.getElementsByName("seq")로 같은 이름의 체크박스를 전부 모아checked에 같은 값을 넣습니다. 모든 체크박스의 name을 seq로 통일해 둔 것이 다음 날 다중 삭제(getParameterValues("seq"))의 준비입니다. 오늘은 목록의 "글삭제" 버튼에 폼·서버 처리가 연결되지 않아 다중 삭제는 미구현입니다. → 🖥️ 26. form의 name과 체크박스 전체 선택
④ 강조 개념 6가지 — 오늘 코드 뒤에 숨은 원리
JSP는 처음 요청될 때 서블릿 자바 코드로 바뀌고 컴파일되어 실행됩니다(19. JSP 실행과 문법). 지금 구조처럼 JSP가 요청을 받아 흐름까지 맡으면 MVC1, 서블릿이 흐름을 맡으면 MVC2입니다(20). 값을 담는 보관함은 page·request·session·application 네 가지로, 살아 있는 범위가 다릅니다(21). 파라미터는 브라우저가 보낸 문자열, 속성(attribute)은 서버가 담아 둔 객체입니다(22). 화면 이동은 HTML 링크·JavaScript location.href·서버의 redirect/forward로 나뉩니다(23). 정적 파일을 주는 Web Server와 자바 코드를 실행하는 WAS(Tomcat)의 역할 차이도 정리했습니다(24).
JSPboardController.jsp — 오늘 늘어난 분기 (등록 · 상세 · 단일 삭제)
}else if(command.equalsIgnoreCase("boardinsertform")){
//글쓰기 폼으로 이동 요청
response.sendRedirect("boardInsertForm.jsp");
}else if(command.equalsIgnoreCase("boardInsert")){
//파라미터 받기 : id, title, content
String id = request.getParameter("id");
String title = request.getParameter("title");
String content = request.getParameter("content");
boolean isS=dao.insertBoard(new HkDto(id,title,content));
if(isS){
//그냥 boardlist.jsp페이지로 가면 안되고,
//반드시 컨트롤러를 거쳐서 가야 한다 --> list객체가 필요하기 때문
response.sendRedirect("boardController.jsp?command=boardlist"); // ← 목록 "컨트롤러"로
}else{
response.sendRedirect("error.jsp");
}
}else if(command.equalsIgnoreCase("boardDetail")){
String pesq=request.getParameter("seq");
int seq = Integer.parseInt(pesq); // ← 파라미터는 항상 String: 숫자로 변환
HkDto dto = dao.getBoard(seq);
//dto객체를 저장하고 이동해야 전달됨
request.setAttribute("dto",dto);
pageContext.forward("boardDetail.jsp"); // ← 조회 결과를 보여 줄 땐 forward
}else if(command.equalsIgnoreCase("boardDelete")){//글삭제하기
String pesq=request.getParameter("seq");
int seq = Integer.parseInt(pesq);
boolean isS=dao.deleteBoard(new HkDto(seq,null,null));
if(isS){
response.sendRedirect("boardController.jsp?command=boardlist");
}else{
response.sendRedirect("error.jsp");
}
}
//전체 선택 체크박스 기능
function allSel(bool){
// 체크박스 요소들을 구한다(배열형태)
const chkObj=document.getElementsByName("seq");//[chk,chk...]
for(let i=0;i<chkObj.length;i++){
// 체크박스 요소에 속성 checked에 true/false 대입하면 체크설정 또는 해제
chkObj[i].checked=bool;
}
}
<th><input type="checkbox" name="all" onclick="allSel(this.checked)"/></th> <!-- ← 내 상태를 넘긴다 -->
…
<td><input type="checkbox" name="seq" value="<%=dto.getSeq()%>" /></td> <!-- ← 모두 같은 name -->
<td>
<a href="boardController.jsp?command=boardDetail&seq=<%=dto.getSeq()%>">
<%=dto.getTitle()%>
</a>
</td>
동작 설명없는 글 번호로 상세를 열면 — boardController.jsp?command=boardDetail&seq=999 먼저 예측 → 펼쳐서 확인
getBoard(999)
→ WHERE SEQ = 999 에 맞는 행이 없음 → while 이 한 번도 안 돎
→ 처음 만든 new HkDto() 가 그대로 반환 (seq=0, 나머지 null)
boardDetail.jsp
→ if (dto == null) 검사는 통과하지 못함 (null 이 아니라 "빈 dto"이므로)
→ 제목 입력칸에 <%=dto.getTitle()%> = "null" 이라는 글자가 찍힘
상세 화면에 dto == null 방어가 있지만, DAO가 못 찾았을 때 null이 아니라 빈 객체를 돌려주기 때문에 그 방어가 동작하지 않습니다. 01 프로젝트의 getUser가 dto = null로 시작해 "못 찾으면 null"을 약속했던 것과 비교해 보세요(🖥️ 12번 카드). 이 밖에 seq 숫자 검증 없음, 수정 성공 응답에 같은 <script>가 두 번 출력되는 점도 수업 소스 그대로 남겨 두었습니다(해결된 것으로 표시하지 않음).
32일차에 배운 것 — 핵심 정리
모든 기능이 컨트롤러 하나를 거친다 — command는 "무슨 일", seq는 "몇 번 글"이다.
저장·수정·삭제 뒤엔 redirect(목록 컨트롤러로), 조회 결과를 보여 줄 땐 forward(request에 담아서).
목록 JSP로 바로 가면 안 된다 — list를 만드는 것은 컨트롤러의 목록 분기다.
파라미터는 언제나 String이라 글 번호는 Integer.parseInt로 바꿔 쓴다.
executeUpdate()는 영향 받은 행 수를 돌려주므로 count > 0으로 성공을 판단한다.
수정 폼은 숨은 입력칸으로 seq를 다시 실어 보내야 어느 글을 고칠지 서버가 안다.
체크박스를 같은 name으로 두면 getElementsByName으로 한꺼번에 다루고, 서버에서도 배열로 받을 수 있다(다음 날).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
목록 요청의 전체 과정을 화면 없이 말할 수 있을까?
command 수신 → DAO 조회 → List 객체 → request 속성 저장 → forward → JSP의 HTML 출력 → 브라우저 표시 순서입니다.
글을 저장한 뒤 forward로 boardlist.jsp를 바로 보여 주면 무엇이 문제일까?
두 가지입니다. ① 등록 분기에서는 list를 만든 적이 없으니 boardlist.jsp의 for문이 null을 만나 500이 납니다. ② 설령 목록을 만들어 forward해도 주소창이 글 저장 요청 그대로 남아, 새로고침하면 같은 글이 또 저장됩니다. 그래서 목록 컨트롤러 주소로 redirect합니다.
수정 폼에서 <input type="hidden" name="seq">를 빼면 어떻게 될까?
서버는 어느 글을 고칠지 모릅니다. getParameter("seq")가 null이 되고 Integer.parseInt(null)에서 NumberFormatException이 납니다. 화면에 보이지 않아도 요청에 꼭 실어야 하는 값은 hidden으로 보냅니다.
💼 실무·코딩테스트에서는"저장 후 redirect"는 실무에서 PRG(Post-Redirect-Get) 패턴이라 부르는 기본기입니다 — 새로고침·뒤로가기로 주문이나 결제가 두 번 들어가는 사고를 막아 줍니다. 또 "못 찾으면 null인가, 빈 객체인가"는 메서드를 만들 때 정해 두고 호출하는 쪽이 그 약속대로 검사해야 하는 문제로, 실무 버그의 단골입니다. 같은 날 코딩테스트 15회차는 문자열 일부 가리기(substring·반복)와 SQL CASE 분기였습니다.
한 줄 요약MVC1 게시판에 체크한 글을 한 번에 지우는 다중 삭제를 만들면서 JDBC batch(같은 SQL을 묶어 한 번에 실행)와 트랜잭션(commit·rollback)을 배웠고, 새 프로젝트 03_hello_servlet에서 Servlet으로 GET·POST 요청을 받는 법을 시작했다.
쉽게 말하면다중 삭제는 체크한 글들을 한 바구니에 담아 한 번에 버리는 것입니다. 트랜잭션은 그 바구니를 비울 때 "다 버리거나, 문제가 생기면 하나도 안 버린 걸로 되돌리기"를 약속하는 장치예요. Servlet은 JSP처럼 화면 파일이 아니라 요청을 전담해 받는 자바 직원입니다 — 다음 날부터 게시판의 안내 데스크가 JSP에서 이 직원으로 바뀝니다.
① 체크한 번호들을 배열로 받기 — getParameterValues
목록 표 전체를 <form action="boardController.jsp" method="post" onsubmit="return isAllCheck()">로 감싸고 숨은 입력칸 command=muldel을 넣었습니다. 제출 직전에 isAllCheck()가 체크된 칸 수를 세어 0개면 "하나이상 체크하세요"를 띄우고 false를 돌려 제출을 취소하고, 있으면 confirm()으로 한 번 더 묻습니다. 서버에서는 같은 이름(seq)으로 여러 값이 오므로 getParameter(첫 값 하나만) 대신 request.getParameterValues("seq")로 String[]을 받습니다. 체크 안 한 칸은 아예 전송되지 않습니다. → 🖥️ 26. form의 name과 체크박스
② 트랜잭션 + batch — 여러 DELETE를 한 묶음으로
JDBC는 기본이 자동 커밋이라 SQL 하나를 실행할 때마다 바로 확정됩니다. 그래서 conn.setAutoCommit(false)로 끄면, 이후 실행은 commit() 전까지 확정되지 않은 상태로 남습니다. 같은 SQL DELETE FROM HKBOARD WHERE SEQ = ?에 번호만 바꿔 setString → addBatch()를 반복해 쌓고, executeBatch()로 한 번에 실행합니다(돌려받는 값은 쿼리별 영향 행 수 배열, 예: [1,1,1]). 문제없으면 commit(), SQLException이 나면 rollback()으로 없던 일로 돌리고, finally에서 자동 커밋을 원래대로 켭니다. → 🖥️ 27. 다중 삭제와 트랜잭션
③ 주의 — 이 코드가 보장하는 것과 못 하는 것
강사 코드 대조 기록에 남긴 내용입니다. mulDel은 SQL 오류가 났을 때만 rollback하고, 영향 행 수 배열은 commit()한 뒤에 검사합니다. 그래서 이미 없는 번호가 섞여 그 칸이 0이 나와도 나머지 삭제는 이미 확정된 뒤이고, 메서드는 false를 돌려 error.jsp로 갑니다 — "전부 성공 아니면 전부 취소"가 모든 경우에 보장되지는 않습니다. 또 "하나 이상 체크" 검사가 브라우저에만 있어서, 서버 쪽 검증은 보완 대상입니다. 이날 02 프로젝트에 error.jsp도 생겼습니다.
④ Servlet 시작 — 03_hello_servlet
HelloServlet extends HttpServlet에 @WebServlet(urlPatterns = {"/HelloServlet.do"})를 붙이면 그 주소의 요청이 이 클래스로 옵니다(web.xml에 <servlet>·<servlet-mapping>으로 적는 방법은 주석으로 남겨 비교). GET 요청은 doGet, POST는 doPost가 받는데, 수업 코드는 doPost가 doGet을 그대로 불러 한 곳에서 처리합니다. JSP와 달리 HTML을 response.getWriter()의 out.print("<h1>…")로 직접 문자열로 써서 응답합니다 — 화면 그리기가 불편하다는 것이 곧 "Servlet은 흐름, JSP는 화면"으로 나누는 이유가 됩니다. 서블릿 객체는 처음 요청 때 한 번 만들어져 init() → 요청마다 service()(→doGet/doPost) → 내려갈 때 destroy()의 생명주기를 가집니다. → 🖥️ 28. Servlet 생명주기
JAVAHkDao.mulDel — batch와 트랜잭션 (02_hkboard_MVC1)
//여러글 삭제하기: 파라미터는 seq[] , delete문(여러개)
// --> Transaction 처리가 필요
// --> delete,delete,delete --> 모두 성공해야 성공으로 처리
public boolean mulDel(String[] seqs) {
boolean isS = true;
int[] count= null;// 쿼리 실행 개수 저장
String sql = "DELETE FROM HKBOARD WHERE SEQ = ?";
try(Connection conn=getConnection();){
// 자동 commit 해제 --> rollback할 수 있음
conn.setAutoCommit(false); // ← 여기부터 "확정 보류"
try(PreparedStatement psmt=conn.prepareStatement(sql)){
//batch작업: 동일한 쿼리에 ?만 달라지면서 실행개수가 변하는 작업
for (int i = 0; i < seqs.length; i++) {
psmt.setString(1, seqs[i]);//쿼리 하나 완성
psmt.addBatch();//완성된 쿼리를 준비시켜줌 // ← 바구니에 담기
}
count = psmt.executeBatch();//batch 실행 후 결과는 배열반환
conn.commit();//DB에 반영 // ← 한 번에 확정
}catch (SQLException e) {
conn.rollback();// 오류가 나면 성공한 작업 되돌리기 // ← 없던 일로
e.printStackTrace();
}finally {
//원래 설정으로 되돌리기
conn.setAutoCommit(true);
}
// count[1,1,1,1,1] 각각의 쿼리가 성공하면 1
if(count!=null) { // ← 검사는 commit "뒤"에 한다
for (int i = 0; i < count.length; i++) {
if(count[i]!=1) {
isS=false;
break;
}
}
}else {
isS=false;
}
} catch (SQLException e) {
e.printStackTrace();
isS=false;
}
return isS;
}
// ----- boardController.jsp -----
}else if(command.equalsIgnoreCase("muldel")){//여러글 삭제
// -> 파라미터가 같은 이름으로 여러개의 값을 전송할 경우
String[] seqs = request.getParameterValues("seq"); // ← 체크된 것만 배열로
boolean isS=dao.mulDel(seqs);
if(isS){
response.sendRedirect("boardController.jsp?command=boardlist");
}else{
response.sendRedirect("error.jsp");
}
}
JAVAHelloServlet.java — 요청 받기와 응답 쓰기 (03_hello_servlet, 핵심 부분)
@WebServlet(urlPatterns = {"/HelloServlet.do"}, …) // ← 이 주소의 요청을 받는다
public class HelloServlet extends HttpServlet{
//service(): 요청과 응답에 대한 처리 --> doGet(), doPost() 로 구현함
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse respose) throws ServletException, IOException {
//request에서 제공하는 메서드 일부 살펴보면
System.out.println("요청주소:"+request.getRequestURL());
//파라미터 받기
String param = request.getParameter("param");
//서블릿에서 클라이언트로 응답하기
PrintWriter out=respose.getWriter();
out.print("<h1 style='color:blue;'>서블릿개념</h1>"); // ← HTML을 문자열로 직접 쓴다
out.print("<h2>서블릿 기본 내용 알아보기</h2>");
out.print("<h2>서블릿에서 받은 파라미터:"+param+"</h2>");
out.print("<h3><a href='index.jsp'>메인으로 돌아가기</a></h3>");
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
//post방식으로 요청된 req,resp를 doGet으로 전달하면
// --> doGet에서 요청, 응답처리 할 수 있다.
doGet(req, resp); // ← GET·POST를 한 곳에서
}
@Override
public void destroy() {
System.out.println("요청이 더이상 없으면 자동으로 서블릿 객체를 소멸시킨다.");
}
}
동작 설명index.jsp의 "요청하기" 링크(HelloServlet.do?param=servlet)를 눌렀을 때 화면에 찍히는 글 먼저 예측 → 펼쳐서 확인
서블릿개념 (h1, 파란색)
서블릿 기본 내용 알아보기 (h2)
서블릿에서 받은 파라미터:servlet (h2)
메인으로 돌아가기 (h3 링크)
글자 내용은 코드로 정해집니다. 단, 한글이 제대로 보이려면 응답의 문자 인코딩이 지정되어야 합니다 — doGet 안의 setContentType은 주석 처리되어 있고, 그 일을 다음 날 만든 인코딩 Filter가 맡습니다(34일차). 또 destroy()의 메시지는 요청이 끝날 때마다가 아니라, 서버가 서블릿을 내릴 때(서버 종료·앱 재배포) 한 번 찍힙니다.
33일차에 배운 것 — 핵심 정리
같은 이름으로 여러 값이 오면 getParameterValues("이름")으로 String[]을 받는다. 체크 안 한 체크박스는 전송되지 않는다.
트랜잭션 = 여러 작업을 한 묶음으로: setAutoCommit(false) → 실행 → commit(), 오류 시 rollback(), 끝나면 setAutoCommit(true).
batch = 같은 SQL에 값만 바꿔 addBatch()로 쌓고 executeBatch()로 한 번에 실행, 결과는 쿼리별 영향 행 수 배열.
"모두 성공해야 성공"을 지키려면 확정(commit) 전에 결과를 검사해야 한다 — 수업 코드는 commit 뒤에 검사한다.
Servlet은 HttpServlet을 상속하고 @WebServlet으로 주소를 연결하며, doGet/doPost가 요청을 받는다.
서블릿 생명주기: 최초 요청 때 생성·init() 한 번 → 요청마다 service() → 내릴 때 destroy().
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
setAutoCommit(false)를 빼고 같은 코드를 돌리면, 세 번째 DELETE에서 오류가 났을 때 어떻게 될까?
자동 커밋 상태에서는 실행된 DELETE가 그 즉시 확정되므로, 오류 전에 지워진 글은 rollback()을 불러도 되돌아오지 않습니다(되돌릴 "보류 중인 작업"이 없음). 일부만 지워진 어중간한 상태가 남습니다. 트랜잭션은 시작(자동 커밋 끄기)부터 해야 의미가 있습니다.
브라우저 검사를 건너뛰고 아무것도 체크하지 않은 채 muldel 요청이 서버에 오면?
체크된 칸이 없으니 getParameterValues("seq")가 null을 돌려주고, mulDel(null) 안의 seqs.length에서 NullPointerException이 납니다(이 예외는 SQLException이 아니라 catch에 안 걸림 → 500). 화면 검사는 편의일 뿐이라 서버에서도 다시 검사해야 합니다.
수업 코드의 doPost는 왜 doGet(req, resp) 한 줄뿐일까?
GET이든 POST든 처리할 내용이 같기 때문입니다. 같은 코드를 두 번 쓰지 않으려고 POST로 온 요청을 그대로 doGet에 넘겨 한 곳에서 처리합니다. 다만 등록·삭제처럼 데이터를 바꾸는 요청은 실무에서 POST만 받도록 나누는 경우가 많습니다(38일차 GET·POST 나누기).
💼 실무·코딩테스트에서는트랜잭션은 면접 단골입니다 — 계좌 이체("출금과 입금은 둘 다 되거나 둘 다 안 돼야 한다")로 설명하고, ACID(원자성·일관성·격리성·지속성) 중 오늘 다룬 것이 원자성이라고 연결하면 좋습니다. 실무에서는 JDBC로 직접 commit·rollback을 쓰기보다 스프링의 @Transactional을 붙이는데, 42일차에 바로 그걸 배웁니다. batch는 수천 건을 넣을 때 왕복 횟수를 줄이는 성능 기법으로 실무에서 자주 씁니다.
한 줄 요약03_hello_servlet에서 서블릿이 쓰는 세 가지 저장소·설정 객체(서블릿 하나용 ServletConfig, 앱 전체용 ServletContext, 브라우저별 HttpSession)를 구분하고, 모든 요청 앞에서 UTF-8 인코딩을 처리하는 Filter를 만들었다. 그리고 새 프로젝트 04_hkboard_MVC2에서 게시판의 안내 데스크를 JSP 컨트롤러에서 Servlet 컨트롤러로 옮기기 시작했다(목록·글쓰기 폼·등록까지).
쉽게 말하면건물로 치면 입구에 공통 검사대(Filter)를 세워 모든 손님의 짐(요청)을 같은 방식으로 정리하고, 직원 개인 수첩(Config)·건물 공용 게시판(Context)·손님별 사물함(Session)을 구분한 날입니다. 그리고 접수 창구를 JSP에서 Servlet 직원에게 넘기기 시작했어요 — 이제 손님은 ?command=… 번호표 대신 boardlist.board 같은 주소 자체로 용건을 말합니다.
① ServletConfig · ServletContext · HttpSession — 누구와 공유하나
ServletConfig는 서블릿 하나 전용 설정입니다. @WebInitParam(name="name", value="한경닷컴")으로 적은 값을 init(ServletConfig config) 안에서 config.getInitParameter("name")으로 읽습니다. ServletContext는 웹 앱 전체가 공유하는 공간으로 JSP의 application 객체와 같고, setAttribute로 값을 담거나 web.xml의 <context-param>(여기서는 driver)을 getInitParameter로 읽습니다. HttpSession은 request.getSession()으로 얻는 브라우저별 저장소로, 브라우저 쿠키의 세션 ID(JSESSIONID)로 구별됩니다 — 나중에 로그인 상태를 기억하는 자리입니다. → 🖥️ 29. ServletConfig · ServletContext · Session · 🖥️ 21. 네 가지 scope
② 수업 예제의 init — super.init(config)를 빼면 생기는 일
HelloServlet은 init()과 init(ServletConfig config)를 둘 다 재정의했는데, 뒤쪽에서 super.init(config)를 부르지 않습니다. 부모(GenericServlet)의 init(ServletConfig)는 원래 config를 저장해 두고 그다음 init()을 불러 주는 메서드라서, 이걸 덮어쓰면 ① 매개변수 없는 init()이 호출되지 않아 "init():최초 한번 실행"이 찍히지 않고, ② 나중에 getServletConfig()·getServletContext()를 쓰면 저장된 config가 없어 문제가 됩니다. 매개변수 config를 직접 쓰는 이 예제 안에서는 동작하지만, 재정의할 땐 super.init(config)를 먼저 부르거나 매개변수 없는 init()만 재정의하는 것이 안전합니다(강사 코드 대조 기록 4번).
③ Filter — 요청 앞뒤에 끼는 공통 검사대
@WebFilter(urlPatterns = {"/*"}, initParams = {@WebInitParam(name="encoding", value="utf-8")})는 모든 주소의 요청이 서블릿에 닿기 전에 이 필터를 지나게 합니다. doFilter 안에서 chain.doFilter(request, response)보다 위에 쓴 코드는 요청이 들어갈 때, 아래에 쓴 코드는 응답이 나올 때 실행됩니다. 여기서 request.setCharacterEncoding(encode)와 response.setContentType("text/html;charset="+encode)를 한 번만 해 두면, 모든 서블릿·JSP가 매번 인코딩 코드를 쓸 필요가 없습니다. ServletRequest에는 getRequestURI()가 없어서 (HttpServletRequest)로 자식 타입 형변환해 썼습니다. → 🖥️ 30. 인코딩 Filter · 🖥️ 14. 한글이 깨지는 자리
④ 04_hkboard_MVC2 — 주소로 분기하는 Servlet 컨트롤러
@WebServlet("*.board")로 .board로 끝나는 모든 요청을 BoardController 서블릿 하나가 받습니다. 어떤 요청인지는 command 파라미터 대신 주소에서 잘라 냅니다 — getRequestURI()(예: /04_hkboard_MVC2/boardlist.board)에서 getContextPath()(/04_hkboard_MVC2) 길이만큼 잘라 "/boardlist.board"를 얻고, 나머지 분기 7단계는 02와 같습니다. JSP의 pageContext.forward는 서블릿에 없으므로 request.getRequestDispatcher(url).forward(request, response)를 dispatch() 메서드로 묶었습니다. 9월 18일에 바꾼 것은 목록·글쓰기 폼·등록까지이고(코드의 "여기까지 요청URL 변경했음" 주석), 상세·수정·삭제·다중 삭제는 아직 JSP 컨트롤러 경로가 남아 있어 MVC2 전환 완료로 표시하지 않았습니다. → 🖥️ 31. Servlet 프런트 컨트롤러
@WebServlet(
urlPatterns = {"/HelloServlet.do"},
initParams = {
@WebInitParam(name="name",value="한경닷컴") // ← 이 서블릿 전용 초기값
}
)
public class HelloServlet extends HttpServlet{
//init(): 서블릿 객체가 생성될때 최초 한번 실행되는 메서드
@Override
public void init() throws ServletException {
System.out.println("init():최초 한번 실행"); // ← 아래 init(config) 때문에 호출되지 않는다
}
@Override
public void init(ServletConfig config) throws ServletException {
//서블릿에 정의된 초기값 가져오기
String name=config.getInitParameter("name"); // ← ServletConfig: 서블릿 하나용
System.out.println("서블릿 초기값:"+name);
//ServletContext객체 얻어오기(JSP: application객체)
ServletContext application=config.getServletContext(); // ← 앱 전체가 공유
application.setAttribute("id", "hk");//객체 저장
System.out.println((String)application.getAttribute("id"));
//web.xml에 정의하고 가져올 경우
System.out.println((String)application.getInitParameter("driver"));
}
protected void doGet(HttpServletRequest request, HttpServletResponse respose) … {
…
//session 객체 얻어오기
HttpSession session = request.getSession(); // ← 브라우저별 저장소
session.setAttribute("id", "hk");
// --> 요청한 pc에 브라우저에 쿠키를 확인해보면 session_id
}
}
<!-- web.xml : 앱 전체 초기값 -->
<context-param>
<param-name>driver</param-name>
<param-value>org.mariadb.jdbc.Driver</param-value>
</context-param>
실행 결과HelloServlet이 처음 요청될 때 서버 콘솔 (init 부분) 먼저 예측 → 펼쳐서 확인
서블릿 초기값:한경닷컴
hk
org.mariadb.jdbc.Driver
세 줄 모두 코드와 설정 값으로 정해지는 출력입니다. "init():최초 한번 실행"은 나오지 않습니다 — 매개변수 없는 init()을 불러 주는 것은 부모의 init(ServletConfig)인데, 그 메서드를 덮어쓰고 super.init(config)를 부르지 않았기 때문입니다.
JAVAEncodeFilter.java — 모든 요청 앞뒤에서 (04_hkboard_MVC2/util)
@WebFilter(
urlPatterns = {"/*"},//여러 패턴 정의 가능 // ← 모든 요청이 지나간다
initParams = {
@WebInitParam(name="encoding",value="utf-8")
}
)
public class EncodeFilter extends HttpFilter{
private String encode;
@Override
public void init(FilterConfig config) throws ServletException {
encode=config.getInitParameter("encoding");
}
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("요청할때 실행할 코드");
//인코딩처리하는 코드
request.setCharacterEncoding(encode); // ← 파라미터를 읽기 "전에" 해야 효과
response.setContentType("text/html;charset="+encode);
//자식타입으로 형변환하기 --> HttpSevletRequest가 됨
HttpServletRequest httpReq=(HttpServletRequest)request;
System.out.println("요청URI(filter):"
+httpReq.getRequestURI());
//chain.doFilter 코드 실행 전 위치에 있는 코드--> 요청할때 실행될 코드
chain.doFilter(request, response); // ← 여기서 서블릿·JSP로 넘어간다
//응답할때 실행될 코드
System.out.println("응답할때 실행할 코드");
}
}
JAVABoardController.java — 9월 18일에 전환한 부분 (04_hkboard_MVC2)
@WebServlet("*.board") // ← .board로 끝나는 요청 전부
public class BoardController extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI=request.getRequestURI();
String contextPath=request.getContextPath();
String pathInfo=request.getPathInfo();
System.out.println(requestURI+"\n"+contextPath+"\n"+pathInfo);
//command값 구하기 : "/boardlist.board" 추출
String command=requestURI.substring(contextPath.length()); // ← 주소가 곧 command
//2단계: DAO 객체 생성
HkDao dao=new HkDao();
//3단계: 요청분기(요청확인하기)
if(command.equalsIgnoreCase("/boardlist.board")){//글목록요청확인
List<HkDto>list=dao.getAllList();
request.setAttribute("list", list);
// response.sendRedirect("boardlist.jsp");// 객체 전달 못함 (X)
dispatch("boardlist.jsp", request, response); // ← 서블릿식 forward
}else if(command.equalsIgnoreCase("/boardInsertForm.board")){//글쓰기폼 이동
response.sendRedirect("boardInsertForm.jsp");
}else if(command.equalsIgnoreCase("/boardInsert.board")){// 글추가하기
String id = request.getParameter("id");
String title = request.getParameter("title");
String content = request.getParameter("content");
boolean isS= dao.insertBoard(new HkDto(id, title, content));
if(isS){
response.sendRedirect("boardlist.board");
//테이블 수정 요청에 대한 응답은 forward를 사용하면 안된다
// --> 주소창에 url이 갱신되지 않아서 동일한 양식을 또 제출하게 된다
}else{
response.sendRedirect("error.jsp");
}//=================여기까지 요청URL 변경했음==================
}
…
}
// forward 기능 구현
public void dispatch(String url, HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.getRequestDispatcher(url).forward(request, response);
}
}
동작 설명/04_hkboard_MVC2/boardlist.board 요청 하나가 지나는 순서 (서버 콘솔) 먼저 예측 → 펼쳐서 확인
요청할때 실행할 코드 ← Filter: chain.doFilter 앞
요청URI(filter):/04_hkboard_MVC2/boardlist.board
/04_hkboard_MVC2/boardlist.board ← BoardController: getRequestURI()
/04_hkboard_MVC2 ← getContextPath()
null ← getPathInfo(): 확장자 매핑(*.board)에선 null
1단계:드라이버 로딩 성공 ← new HkDao() → 부모 DataBase 생성자
(getAllList → boardlist.jsp로 forward → 표 그리기)
응답할때 실행할 코드 ← Filter: chain.doFilter 뒤
컨텍스트 경로가 /04_hkboard_MVC2로 배포됐을 때의 순서입니다. 필터의 출력이 서블릿 출력을 앞뒤로 감싸는 모양이 "요청 → 처리 → 응답" 흐름 그대로입니다. substring(contextPath.length())를 하면 "/boardlist.board"만 남아 분기 조건과 비교됩니다.
34일차에 배운 것 — 핵심 정리
ServletConfig = 서블릿 하나의 초기값, ServletContext = 앱 전체 공유(JSP의 application), HttpSession = 브라우저별 저장소(JSESSIONID 쿠키로 구별).
init(ServletConfig)를 재정의하면 super.init(config)를 먼저 불러야 config가 저장되고 매개변수 없는 init()도 호출된다.
Filter의 chain.doFilter()앞은 요청 때, 뒤는 응답 때 실행된다 — 인코딩처럼 모든 요청에 필요한 일을 한 곳에 둔다.
setCharacterEncoding은 파라미터를 처음 읽기 전에 해야 효과가 있다 — 그래서 가장 앞단인 필터가 맡는다.
MVC2 컨트롤러는 *.board 같은 URL 패턴으로 요청을 모으고, getRequestURI()에서 컨텍스트 경로를 잘라 분기한다.
04는 목록·글쓰기 폼·등록까지만 서블릿으로 옮긴 상태다 — "전환 시작"과 "전환 완료"를 구분한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
필터에서 request.setCharacterEncoding을 chain.doFilter "아래"로 옮기면 POST 한글이 어떻게 될까?
깨집니다.chain.doFilter 안에서 이미 서블릿이 getParameter로 값을 읽어 버렸고, 인코딩은 파라미터를 처음 읽을 때 결정되므로 그 뒤에 지정하면 소용이 없습니다. 인코딩은 반드시 요청 쪽(앞)에서 합니다.
ServletContext에 담은 "id"와 Session에 담은 "id"는 무엇이 다를까?
공유 범위가 다릅니다. ServletContext의 값은 그 앱에 접속한 모든 사용자가 같은 값을 봅니다(한 명이 바꾸면 모두에게 바뀜). Session의 값은 브라우저마다 따로 있어서, 로그인한 사용자 정보처럼 "이 사람만의 값"을 둘 때 씁니다.
04에서 boardlist.board 대신 boardlist.jsp를 주소창에 직접 열면?
04의 JSP는 아직 webapp 바로 아래에 있어 직접 열립니다. 하지만 서블릿을 거치지 않았으니 list가 없어 for문에서 500이 납니다(31일차와 같은 이유). 이 문제를 구조로 막는 방법이 JSP를 WEB-INF 안에 두는 것으로, 36일차 스프링 프로젝트부터 그렇게 바뀝니다.
💼 실무·코딩테스트에서는Filter는 실무에서 인코딩·로그인 검사·요청 로그·CORS처럼 "모든 요청에 공통으로 필요한 일"을 맡고, 스프링에서도 CharacterEncodingFilter(36일차)나 Spring Security가 같은 자리에서 동작합니다. 면접에서는 "서블릿 생명주기"와 함께 "서블릿 객체는 보통 하나만 만들어지고 요청마다 스레드가 그 객체를 함께 쓴다 — 그래서 서블릿에 요청별 값을 필드로 두면 안 된다"가 자주 나옵니다. Session은 로그인 유지의 기본이라 쿠키와의 차이(서버 저장 vs 브라우저 저장)를 설명할 수 있어야 합니다.
한 줄 요약04를 복사해 05_hkboard_MVC2_JSTL을 만들고, 목록·상세 화면의 스크립틀릿(<% %>)을 EL(${ })과 JSTL 태그로 바꿨다. EL·JSTL 전환은 JSP만 손댄 작업이고, 같은 날 실행이 막힌 원인을 찾으며 DB 설정·오류 출력 코드도 바로잡았다.
쉽게 말하면가구(자바 코드)는 그대로 두고 벽지(화면)만 새로 바른 날입니다. 화면 속 자바 문법을 ${ }와 <c:forEach> 같은 태그로 바꿨더니 JSP가 HTML처럼 읽히게 됐어요. 형변환·import·<% } %> 짝 맞추기가 사라지고, 값이 없을 때 화면에 null이 찍히던 문제도 같이 없어집니다.
① EL — ${이름}은 scope를 순서대로 찾고, 점(.)은 getter를 부른다
${list}라고 쓰면 EL이 page → request → session → application 순서로 "list"라는 이름을 찾아 꺼냅니다. 그래서 컨트롤러가 request.setAttribute("list", …)로 담은 이름만 맞으면, JSP에서 getAttribute·형변환·import가 전부 필요 없습니다. ${dto.title}은 필드에 직접 가는 게 아니라 getTitle()을 호출합니다 — DTO의 getter 이름 규칙이 곧 화면 문법이 되는 셈이죠. 값이 null이면 <%= %>는 "null" 글자를 찍지만 EL은 빈칸을 찍습니다(web-16에서 "일부러 남긴 것"이 여기서 풀립니다). ${requestScope.dto.seq}처럼 scope를 직접 지정할 수도 있습니다. → 🖥️ 32. EL
② JSTL 설치 — jar 2개와 jakarta taglib
JSTL은 표준이지만 Tomcat에 들어 있지 않아서WEB-INF/lib에 API jar(jakarta.servlet.jsp.jstl-api-3.0.1)와 구현 jar(jakarta.servlet.jsp.jstl-3.0.1) 두 개를 함께 넣습니다. API만 넣으면 실행할 구현이 없어 실패합니다. JSP 맨 위에는 <%@ taglib prefix="c" uri="jakarta.tags.core" %>를 씁니다 — Tomcat 10부터 javax가 jakarta로 바뀐 여파라, 인터넷 예제의 http://java.sun.com/jsp/jstl/core를 그대로 쓰면 태그를 찾지 못합니다. → 🖥️ 33. JSTL 설치
③ c:forEach · c:choose — 반복과 분기를 태그로
스크립틀릿 for문은 <c:forEach items="${list}" var="dto">가 대신합니다. list가 null이어도 예외 없이 0번 반복하고 지나갑니다. 글이 없을 때 안내 문구를 보여 주려고 <c:choose> 안에 <c:when test="${empty list}">와 <c:otherwise>를 두었는데, empty는 null·빈 문자열·빈 컬렉션을 모두 true로 봅니다. <c:when>·<c:otherwise>는 <c:choose>의 직속 자식이어야 해서, </c:choose>를 먼저 닫아 버리면 500이 납니다(이날 실제로 겪은 오류). → 🖥️ 34. c:forEach · 🖥️ 35. c:choose
④ 바뀐 것은 View뿐 — 그리고 같은 날 고친 "조용한 실패"
EL·JSTL 전환에서 Controller·DAO·DTO의 로직은 바뀌지 않았습니다 — 04와 나란히 놓으면 JSP만 다릅니다. 다만 boardDetail.jsp 위쪽에는 이제 쓰이지 않는 스크립틀릿 선언(HkDto dto = (HkDto)request.getAttribute("dto");)이 아직 남아 있습니다. 실행 중에는 글쓰기가 계속 error.jsp로 튕겼는데, 04도 똑같이 실패해서 "05 코드 문제가 아니다"가 바로 갈렸고, 원인은 DB 비밀번호 환경변수 부재였습니다. 이를 계기로 DataBase에 printSqlError(작업 이름·SQLState·오류코드를 한 줄로)를 만들고, INSERT에 컬럼 목록을 명시했습니다(05에 반영, 04에는 없음). → 🖥️ 36. 04→05 핵심 정리 · 🖥️ 37. 디버깅 기록
JSPboardDetail.jsp (05) — 본문은 EL, 위쪽엔 쓰이지 않는 선언이 남음
<%@page import="com.hk.board.dto.HkDto"%>
<%
// requestScope에서 HkDto객체 가져오기
HkDto dto =(HkDto)request.getAttribute("dto"); // ← 아래에서 안 쓰인다: 지워도 되는 두 줄
%>
<body>
<h1>글 상세보기</h1>
<form action="boardUpdate.board" method="post">
<input type="hidden" name="seq" value="${requestScope.dto.seq}"/> <!-- ← scope 직접 지정 -->
…
<td>${requestScope.dto.id}</td>
…
<td><input type="text" name="title" value="${requestScope.dto.title}"
required="required" /></td>
…
<td><textarea rows="10" cols="60" name="content"
required="required">${requestScope.dto.content}</textarea></td>
JAVADataBase.printSqlError + HkDao.insertBoard (05) — 같은 날 디버깅하며 고친 곳
//getenv("이름")은 "값"이 아니라 "환경변수 이름"으로 찾는다
// -> 여기에 비밀번호 '값'을 적으면 그 이름의 환경변수를 찾는 것이라 항상 null이 된다
String password = System.getenv("STUDY_DB_PASSWORD");
//SQLException을 알아보기 쉽게 콘솔에 출력
// 예) SQLState=28000 -> 계정/비밀번호 문제, 42S02 -> 테이블 없음
protected void printSqlError(String step, SQLException e) {
System.err.println("[DB오류] " + step
+ " | SQLState=" + e.getSQLState() // ← "조회 실패"와 "글 0건"을 구분하게
+ " | 오류코드=" + e.getErrorCode()
+ " | " + e.getMessage());
e.printStackTrace();
}
// ----- HkDao.insertBoard -----
//컬럼명을 명시해야 테이블에 컬럼이 추가/순서변경 되어도 깨지지 않는다
//SEQ는 AUTO_INCREMENT라서 아예 빼면 DB가 알아서 채워준다
String sql = " INSERT INTO HKBOARD(ID, TITLE, CONTENT, REGDATE) " // ← 04: VALUES(NULL,?,?,?,SYSDATE())
+ " VALUES(?,?,?,SYSDATE()) ";
동작 설명글이 하나도 없을 때 04와 05의 목록 화면 먼저 예측 → 펼쳐서 확인
04 (스크립틀릿 for) list가 빈 List → 머리줄과 버튼만 있는 빈 표
05 (c:choose) ${empty list}가 true → "--작성된 글이 없습니다.--" 한 줄이 들어간 표
컨트롤러는 DB 결과가 없어도 빈 ArrayList를 담아 보내므로(getAllList가 new ArrayList<>()로 시작) 04도 500은 나지 않습니다. 05는 empty로 그 상황을 사용자에게 설명하는 문구를 보여 준다는 점이 다릅니다. 단, <c:forEach>가 조용히 0번 반복하기 때문에 목록이 비어 보이면 DB가 비었는지, scope 이름이 틀렸는지를 따로 확인해야 합니다.
35일차에 배운 것 — 핵심 정리
EL ${이름}은 page → request → session → application 순으로 찾고, 없으면 예외 대신 빈 값이다.
${dto.title}은 getTitle()을 호출한다 — getter가 없으면 값을 읽지 못한다.
EL은 null을 빈칸으로, <%= %>는 "null" 글자로 찍는다.
JSTL은 API + 구현 jar 2개를 WEB-INF/lib에 넣고, taglib URI는 jakarta.tags.core를 쓴다.
<c:forEach>는 null·빈 목록이면 0번 반복하고, empty는 null·빈 문자열·빈 컬렉션을 모두 true로 본다.
<c:when>·<c:otherwise>는 <c:choose>의 직속 자식이어야 한다.
"EL·JSTL로 바꿨다"와 "스크립틀릿이 한 줄도 없다"는 다르다 — 05의 boardDetail.jsp에는 쓰이지 않는 선언이 남아 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
컨트롤러가 request.setAttribute("boardList", list)로 바꿔 담았는데 JSP는 ${list}를 그대로 쓰면?
오류 없이 "글이 없습니다"가 나옵니다. EL은 "list"라는 이름을 네 scope에서 못 찾으면 예외 대신 빈 값을 주고, ${empty list}가 true가 되기 때문입니다. 그래서 EL 화면에서는 "조용히 비어 보이는" 버그가 생기기 쉽고, 담는 이름과 꺼내는 이름을 꼭 맞춰야 합니다.
JSTL jar를 API 하나만 넣으면 어떻게 될까?
편집기에서는 태그가 인식될 수 있어도 실행 시점에 실제로 일할 구현 클래스가 없어 화면이 실패합니다. 규격(API)과 구현은 별개라 두 jar를 함께 넣어야 합니다. (반대로 이클립스에 Unknown tag 표시만 남는 것은 검증기가 jar를 아직 못 읽은 것으로, Refresh·Clean으로 사라지며 실행과는 별개입니다.)
System.getenv("내가쓰는비밀번호")처럼 이름 자리에 값을 적으면 왜 항상 실패할까?
getenv는 괄호 안의 문자열을 환경변수 이름으로 보고 찾습니다. 그런 이름의 환경변수는 없으니 언제나 null이 돌아오고, 비밀번호 없이 접속하다 인증 오류가 납니다. 이름(STUDY_DB_PASSWORD)을 적고, 값은 실행 환경에 넣어 둡니다.
💼 실무·코딩테스트에서는"화면에서 자바 코드를 걷어낸다"는 생각은 이후 Thymeleaf 같은 템플릿 엔진까지 그대로 이어집니다. 실무에서 꼭 알아 둘 함정 하나: JSP 본문에 쓴 ${dto.title}은 HTML 특수문자를 바꾸지 않고 그대로 출력합니다. 사용자가 제목에 <script>를 넣으면 그대로 실행될 수 있어(XSS), 사용자 입력은 <c:out value="${dto.title}"/>처럼 이스케이프해서 찍는 것이 안전합니다. 또 오늘의 디버깅처럼 "바뀐 쪽과 안 바뀐 쪽을 같이 돌려 보는" 비교 습관은 원인을 빠르게 좁히는 실무 기술입니다.
Spring MVCMaven pom.xmlDispatcherServletViewResolver@Controllercomponent-scan
한 줄 요약Maven 기반 스프링 웹 프로젝트를 새로 만들어, /home.do 하나가 화면까지 도달하는 최소 경로를 구성했다. 04·05에서 내가 직접 짜던 요청 분기·화면 경로 조립·인코딩 필터를 스프링의 DispatcherServlet·@RequestMapping·ViewResolver·CharacterEncodingFilter가 대신한다.
쉽게 말하면직접 하던 요청 분기·화면 경로 찾기·한글 인코딩 일을 스프링이라는 관리 회사에 맡기기 시작한 날입니다. 나는 "이 주소는 이 메서드가 맡는다"는 표시(애너테이션)와 "화면 이름은 home"이라는 이름만 적고, 실제로 찾아가고 연결하는 일은 회사가 합니다. 오늘은 /home.do 한 길이 화면까지 가도록 뼈대를 짰습니다.
① Maven — jar를 복사하지 않고 pom.xml에 선언
05까지는 JSTL·드라이버 jar를 받아 WEB-INF/lib에 직접 복사했습니다. 06부터는 pom.xml에 "이 라이브러리 이 버전"을 선언만 하면 Maven이 내려받고, 그 라이브러리가 필요로 하는 라이브러리까지 따라옵니다. 기준은 Spring 6.1.13 · Java 21 · jakarta 네임스페이스입니다. 서블릿·JSP API에 붙은 <scope>provided</scope>는 "컴파일할 땐 필요하지만 톰캣이 이미 갖고 있으니 war에 넣지 말라"는 뜻입니다. pom.xml에는 MyBatis·DBCP·MariaDB 드라이버가 미리 들어 있는데, 오늘은 쓰지 않고 다음 진도(DB 연동)를 위한 준비입니다. → 🖥️ 38. 스프링을 왜 쓰나
② web.xml — 설정 두 벌과 *.do 매핑
web.xml이 스프링 설정 XML 두 개를 가리킵니다. ContextLoaderListener가 root-context.xml(DB·서비스처럼 앱 전체가 쓰는 것)을, DispatcherServlet이 servlet-context.xml(Controller·ViewResolver처럼 화면에 딸린 것)을 읽습니다. DispatcherServlet은 *.do로 매핑되어 모든 .do 요청의 정문이 됩니다 — 04의 @WebServlet("*.board") 컨트롤러가 하던 자리를 스프링 서블릿이 맡는 것이죠. 한글은 05까지 직접 만든 EncodeFilter 대신 스프링의 CharacterEncodingFilter를 등록해 처리합니다. 오늘 root-context.xml은 비어 있습니다(아직 DB 설정 전). → 🖥️ 39. 요청이 지나는 길
③ servlet-context.xml — 네 줄이 하는 일
<mvc:annotation-driven/>은 @Controller·@RequestMapping 방식을 쓰겠다는 선언, <mvc:resources>는 js·css·이미지 같은 정적 파일 경로, InternalResourceViewResolver는 prefix(/WEB-INF/views/) + 뷰 이름 + suffix(.jsp)로 JSP 경로를 조립, <context:component-scan base-package="com.hk.board"/>는 그 패키지에서 @Controller가 붙은 클래스를 찾아 스프링이 객체를 만들어 보관(빈 등록)하게 합니다. @Controller는 "등록해 달라"는 표시일 뿐이고 실제로 찾는 건 component-scan이라, 둘 중 하나만 있으면 동작하지 않습니다. → 🖥️ 40. 설정에서 자주 하는 실수
④ HomeController — 분기 코드 없이 표시와 이름만
@RequestMapping(value="/home.do", method=RequestMethod.GET)을 메서드에 붙이면, /home.do GET 요청이 오면 스프링이 그 메서드를 찾아 호출합니다. 04·05에서 getRequestURI()를 자르고 if로 비교하던 코드가 통째로 사라졌습니다. 메서드는 "home"이라는 뷰 이름만 돌려주고, ViewResolver가 /WEB-INF/views/home.jsp로 forward합니다. JSP가 WEB-INF 안에 있어 브라우저가 주소로 직접 열 수 없고, 반드시 Controller를 거치게 구조가 강제됩니다. 같은 클래스의 main.do는 "main"을 돌려주지만 /WEB-INF/views/main.jsp가 없어서 화면을 찾지 못합니다 — 이름과 파일이 어긋나도 컴파일 때는 아무도 알려 주지 않는다는 점이 "이름만 돌려주기"의 뒷면입니다.
<!-- contextConfigLocation라는 이름을 리스너가 찾는다 -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/root-context.xml</param-value> <!-- ← 앱 전체 설정 -->
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<!-- DispatcherServlet이라는 객체를 Servlet으로 등록 -->
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/appServlet/servlet-context.xml</param-value> <!-- ← 화면 쪽 설정 -->
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>*.do</url-pattern> <!-- ← .do 요청의 정문 -->
</servlet-mapping>
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <!-- ← 내 EncodeFilter 대신 -->
<init-param>
<param-name>encoding</param-name>
<param-value>UTF-8</param-value>
</init-param>
<init-param>
<param-name>forceEncoding</param-name>
<param-value>true</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
XMLservlet-context.xml — 애너테이션 · 정적 파일 · ViewResolver · 스캔
<!-- Spring MVC @Controller같은 어노테이션 방식으로 운영을 하자~ -->
<mvc:annotation-driven/>
<!-- 정적파일 경로(js, css, img)를 설정 -->
<mvc:resources location="/resources/" mapping="/**"></mvc:resources>
<!-- JSP 셋팅 : prefix/페이지이름suffix 경로를 만들어서 찾아줌 -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/" /> <!-- ← 앞에 붙일 것 -->
<property name="suffix" value=".jsp" /> <!-- ← 뒤에 붙일 것 -->
</bean>
<!-- 객체를 xml에 등록하지 않아도 생성해서 사용할 수 있게 한다. -->
<context:component-scan base-package="com.hk.board" /> <!-- ← @Controller를 찾아 빈으로 등록 -->
JAVAHomeController.java — 분기 코드가 사라진 컨트롤러
@Controller // ← "빈으로 등록해 주세요" 표시
public class HomeController {
@RequestMapping(value ="/home.do",method = RequestMethod.GET) // ← 이 주소는 이 메서드가 맡는다
public String home() {
//페이지 이름만 작성한다
// --> WEB-INF/views + home + .jsp : ViewResolver가 해줌
return "home";
}
@RequestMapping(value ="/main.do",method = RequestMethod.GET)
public String main() {
return "main"; // ← main.jsp가 없다: 화면을 못 찾는다
}
}
동작 설명index.jsp의 HOME 링크(home.do)가 화면이 되기까지 먼저 예측 → 펼쳐서 확인
설정과 코드를 따라가며 정리한 흐름입니다(이날 톰캣에서 /home.do를 열어 본 기록은 저장소에 없습니다 — 06 README의 검증 범위 참고). 04의 BoardController가 직접 하던 "주소 자르기 → if 비교 → 경로 적어 forward" 세 가지가 각각 DispatcherServlet·@RequestMapping·ViewResolver로 넘어간 것이 보입니다.
36일차에 배운 것 — 핵심 정리
Maven 프로젝트 — jar를 직접 넣지 않고 pom.xml에 선언한다. provided는 "서버에 이미 있으니 war에 넣지 말라".
web.xml의 설정은 두 벌 — root-context(앱 전체: DB·서비스)와 servlet-context(화면: Controller·ViewResolver).
DispatcherServlet이 *.do 요청을 모두 받는 프론트 컨트롤러다.
@Controller(표시) + component-scan(찾아서 등록)이 함께 있어야 컨트롤러가 동작한다.
@RequestMapping이 URL과 메서드를 연결한다 — URL 분기 코드를 직접 쓰지 않는다.
컨트롤러는 뷰 이름만 돌려주고, ViewResolver가 prefix + 이름 + suffix로 JSP 경로를 만든다. 이름과 파일이 어긋나면 실행해야 드러난다.
/WEB-INF/views 아래 JSP는 브라우저가 직접 열 수 없다 — 반드시 Controller를 거친다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
04·05에서 내가 짰던 코드 중 06에서 사라진 것은 무엇인가?
URL을 읽어 분기하는 코드와 forward 경로를 직접 적는 코드, 그리고 인코딩 필터 구현입니다. 각각 @RequestMapping · ViewResolver · CharacterEncodingFilter가 대신합니다. DAO·DTO는 아직 그대로 남아 있습니다.
root-context.xml과 servlet-context.xml을 나누는 기준은?
화면과 상관있는가입니다. Controller·ViewResolver는 servlet-context, DB·서비스처럼 애플리케이션 전체가 쓰는 것은 root-context에 둡니다. 오늘 root-context가 비어 있는 이유도 아직 DB 설정을 하지 않았기 때문입니다.
component-scan의 base-package를 com.hk.other로 잘못 적으면 /home.do는 어떻게 될까?
HomeController가 있는 com.hk.board.controller가 스캔 범위 밖이라 빈으로 등록되지 않고, /home.do를 맡을 메서드가 없어 404가 됩니다. @Controller를 붙였어도 찾아 주는 쪽(스캔)이 못 보면 없는 것과 같습니다. 39일차에 패키지 이름을 바꿀 때 스캔 범위도 같이 바꾼 이유입니다.
💼 실무·코딩테스트에서는면접에서 "스프링 MVC의 요청 처리 흐름"을 물으면 DispatcherServlet → HandlerMapping(어느 컨트롤러?) → Controller → ViewResolver → View 순서로 답하면 됩니다. 오늘 만든 구조가 정확히 그 흐름이에요. 또 "객체를 내가 new 하지 않고 스프링이 만들어 관리한다"는 것이 IoC(제어의 역전)이고, 다음 시간의 @Autowired(DI)로 이어집니다. 실무에서는 대부분 Spring Boot를 써서 이 XML 설정이 자동으로 처리되지만, 오늘처럼 한 번 손으로 연결해 보면 Boot가 무엇을 대신해 주는지 이해할 수 있습니다.
한 줄 요약어제 만든 Spring MVC 뼈대에 DB 조회를 붙이고 역할을 Controller·Service·DAO로 나눴다. 오전에는 06에서 DB 연결 설정(커넥션 풀·MyBatis)과 별칭·Mapper 등록을 연습했고, 오후에는 새 프로젝트 07_hkboard_springMVC에서 게시판 목록을 실제 DB에서 꺼내 JSP에 보여 줬다.
쉽게 말하면어제는 화면 이름만 돌려줬고, 오늘은 실제 DB에서 꺼낸 목록을 화면에 보냅니다. 02~05에서 DAO가 직접 하던 연결 만들기·SQL 문자열·ResultSet 옮겨 담기를 이제 커넥션 풀과 MyBatis가 맡아요. DAO는 "boardList라는 이름의 SQL을 실행해 줘" 한 줄만 말하면 되고, SQL 자체는 XML 파일에 따로 삽니다. 그리고 객체끼리는 new 대신 스프링이 연결해 줍니다.
① root-context.xml — DB 빈 네 개가 사슬처럼 이어진다
36일차에 비어 있던 root-context.xml에 DB 연결 사슬을 등록했습니다. ① PropertyPlaceholderConfigurer가 db.properties를 읽어 ${url} 같은 자리를 채우고 → ② BasicDataSource(DBCP)가 연결(Connection)을 미리 만들어 두고 빌려주고 돌려받는 커넥션 풀이 되고 → ③ SqlSessionFactory가 그 DataSource와 MyBatis 설정(Configuration.xml)을 읽어 SQL 실행 준비를 하는 공장이 되고 → ④ SqlSessionTemplate이 DAO가 실제로 쿼리를 요청하는 창구가 됩니다(열고 닫기는 스프링이 관리). 실제 접속 정보가 든 db.properties는 공개 저장소에 올리지 않고 .example 파일만 둡니다. → 🖥️ 43. DBCP와 SqlSessionTemplate
② MyBatis가 SQL을 찾는 이름 — namespace.id
Configuration.xml은 두 가지를 합니다: com.hk.board.dtos.HkDto를 HkDto라는 별칭으로 줄이고, sqls/BoardMapper.xml을 Mapper로 등록합니다. Mapper 안의 SQL은 namespace + . + id라는 전체 이름으로 찾습니다 — DAO의 namespace="com.hk.board.dao."(끝에 점)과 "boardList"를 붙인 com.hk.board.dao.boardList가 XML의 <mapper namespace="com.hk.board.dao"> 안 <select id="boardList">와 글자 하나까지 맞아야 합니다. DAO의 자바 패키지는 daos인데 namespace는 dao — 문자열로 부르는 지금 방식에선 같을 필요가 없고, 둘이 서로 맞기만 하면 됩니다. 값은 #{seq}처럼 넣는데 #{}는 ? 자리표시자로 바뀌어 안전합니다. → 🖥️ 44. Mapper 이름 · 🖥️ 45. foreach
③ Controller → Service → DAO, 그리고 @Autowired
BoardController(요청·화면 이름 담당) → HkService(작업 흐름 담당) → HkDao(DB 접근 담당)로 역할을 나눴고, Service·DAO는 인터페이스(IHKService·IHkDao)와 구현 클래스로 만들었습니다. 객체는 직접 new하지 않고, @Controller·@Service·@Repository로 빈 등록을 표시한 뒤 필드에 @Autowired를 붙이면 스프링이 같은 타입의 빈을 찾아 넣어 줍니다(의존성 주입, DI). Service가 IHkDao타입으로 받기 때문에 나중에 구현을 바꿔도 Service 코드는 그대로입니다. Controller는 결과를 Model에 addAttribute("list", list)로 담고 "boardlist"를 돌려주며, JSP는 05와 같은 <c:forEach items="${list}">로 그립니다. → 🖥️ 41. 요청 흐름 · 🖥️ 42. 의존성 주입
④ 구현 범위와 오늘의 오류
Controller의 웹 요청은 목록 하나까지입니다. DAO·Service·Mapper에는 등록·상세·수정·삭제·다중 삭제(<foreach>)까지 있지만, 메서드가 있다는 것과 화면 버튼까지 동작한다는 것은 다릅니다 — 요청 연결은 38일차입니다. 실행하며 만난 오류는 Maven 클래스패스 누락(스프링·MyBatis·JSTL을 못 찾음), 메인 링크의 .board와 실제 매핑 .do 불일치, 로컬 DB 인증 실패, 이전 리소스가 남은 배포였고, 하나씩 좁혀 메인·목록이 HTTP 200으로 열리는 것까지 확인했습니다. defaultAutoCommit=false만으로 스프링 트랜잭션이 생기지는 않습니다(트랜잭션 관리자 설정은 42일차). → 🖥️ 46. 오류를 단계별로 좁히기
07 README에 정리한 경로 그대로이고, 이날 로컬 Tomcat에서 메인과 /boardlist.do가 HTTP 200을 반환하는 것까지 확인했습니다. 이 사슬의 어느 한 고리(매핑 주소, 스캔, 주입, namespace.id, DB 접속, Model 이름)만 어긋나도 목록이 안 나옵니다.
37일차에 배운 것 — 핵심 정리
요청 흐름: GET /boardlist.do → DispatcherServlet → BoardController(요청·화면 이름 담당) → HkService(작업 흐름 담당) → HkDao(DB 접근 담당) → Mapper XML의 SQL → 결과 List를 Model에 담아 boardlist.jsp가 <c:forEach>로 출력.
DBCP(BasicDataSource) — DB 연결(Connection)을 미리 만들어 두고 빌려주고 돌려받는 커넥션 풀.
SqlSessionFactory — DataSource와 MyBatis 설정(Configuration.xml: 별칭·Mapper 등록)을 읽어 SQL 실행 준비를 하는 공장.
SqlSessionTemplate — DAO가 selectList("namespace.id")처럼 실제 쿼리를 요청하는 창구(스프링이 열고 닫기를 관리해 준다).
MyBatis는 namespace.id 전체 이름으로 SQL을 찾는다 — DAO가 만드는 문자열과 XML이 글자 하나까지 맞아야 한다.
객체는 직접 new하지 않고 @Controller·@Service·@Repository + @Autowired로 스프링이 연결(의존성 주입)한다. 등록·수정·삭제의 Controller 연결은 다음 구현 범위다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
목록에 글이 나오려면 무엇이 모두 맞아야 할까?
JSP의 링크, DispatcherServlet 매핑, Controller 주소, Service·DAO 주입, Mapper 전체 이름, DB 접속, Model 이름과 JSP의 EL이 연결되어야 한다.
DAO의 namespace를 "com.hk.board.dao"(끝의 점 없이)로 쓰면 무슨 일이 생길까?
문자열이 "com.hk.board.daoboardList"로 붙어 버려 그런 이름의 SQL이 없다는 예외("Mapped Statements collection does not contain value for …")가 납니다. 컴파일은 잘 되고 실행해야 드러나는 오류라, 메시지에 찍힌 전체 이름을 XML과 한 글자씩 비교하는 습관이 필요합니다.
HkService가 HkDao가 아니라 IHkDao 타입으로 받는 이점은?
Service는 "이런 메서드가 있다"는 약속(인터페이스)에만 기대므로, DAO 구현을 다른 클래스(예: 테스트용 가짜 DAO, 다른 DB용 DAO)로 바꿔도 Service 코드를 고칠 필요가 없습니다. 스프링은 IHkDao 타입의 빈을 찾아 넣어 주고, 지금은 그게 HkDao입니다.
💼 실무·코딩테스트에서는Controller–Service–DAO(Repository) 3계층은 스프링 실무의 표준 구조이고, 면접에서 "왜 Service를 따로 두나"(여러 DAO 호출을 묶는 업무 흐름·트랜잭션의 자리)와 "DI의 장점"(결합도가 낮아 교체·테스트가 쉬움)이 자주 나옵니다. MyBatis에서는 #{}는 ?로 바뀌어 SQL 인젝션에 안전, ${}는 글자를 그대로 붙여 위험이라는 차이를 꼭 기억하세요. 커넥션 풀은 "연결을 만드는 비용이 크니 미리 만들어 재사용한다"로 설명하면 됩니다.
한 줄 요약37일차에 목록만 연결했던 07 게시판에 글쓰기·상세·수정·다중 삭제 요청을 Controller 메서드로 하나씩 붙였다. 폼 값은 DTO 하나로 한꺼번에(커맨드 객체), 체크박스 여러 개는 @RequestParam("seq") String[]로 받고, 저장·수정·삭제 뒤에는 redirect:로 다시 보낸다.
쉽게 말하면04·05에서 getRequestURI()와 if문으로 갈라내던 요청을, 이제는 메서드마다 주소표(@RequestMapping)를 붙여 스프링이 알아서 찾아가게 했습니다. 그리고 getParameter를 세 번 부르던 일도 "DTO 상자 하나 주세요"라고 매개변수에 적기만 하면 스프링이 이름이 맞는 칸에 값을 채워 넣어 줍니다.
① 요청 하나에 메서드 하나 — 매핑표
JSP의 링크·폼 주소를 .board에서 .do로 모두 바꾸고(글쓰기 폼·상세·수정·삭제·목록 버튼), BoardController에 boardInsertForm.do(GET) · boardInsert.do(POST) · boardDetail.do(GET) · boardUpdate.do(POST) · mulDel.do(GET·POST)를 추가했습니다. method = RequestMethod.POST처럼 방식까지 지정하면 그 방식이 아닌 요청은 이 메서드로 오지 않습니다. 같은 주소를 GET·POST로 나눠 다르게 처리하는 @GetMapping("/mulDel2.do")·@PostMapping("/mulDel2.do")는 빈 메서드로 연습만 했습니다. → 🖥️ 47. 요청 매핑표 · 🖥️ 51. GET·POST와 삭제
② 파라미터 바인딩 — 커맨드 객체와 @RequestParam, 그리고 -parameters 함정
boardInsert(HkDto dto)처럼 DTO를 매개변수로 두면, 폼 입력칸의 name(id·title·content)과 같은 이름의 setter를 찾아 값을 채운 객체를 넘겨줍니다(커맨드 객체). 상세 보기도 HkDto pdto로 seq를 받아 pdto.getSeq()를 씁니다. 같은 이름이 여러 개인 체크박스는 @RequestParam("seq") String[] seq로 배열을 받습니다. 주의할 점: home.do의 String param처럼 애너테이션 없는 단순 타입은 스프링이 매개변수 이름으로 값을 찾는데, Spring 6.1은 그 이름을 -parameters 옵션으로 컴파일된 정보에서만 읽습니다. 이 PC의 Eclipse·Maven 설정은 이 옵션이 없어 IllegalArgumentException이 납니다 — 수업 주석 "Spring6 에서는 파라미터 받을때 @RequestParam 명시하기"가 이 이야기입니다. → 🖥️ 48. 파라미터 바인딩 · 🖥️ 49. -parameters 함정
③ 뷰 이름을 돌려줄까, redirect:를 돌려줄까
조회 결과를 보여 줄 땐 model.addAttribute("dto", dto) 후 뷰 이름("boardDetail")을 돌려줍니다 — ViewResolver가 JSP로 forward합니다. 저장·수정·삭제가 끝나면 "redirect:boardlist.do"(수정은 "redirect:boardDetail.do?seq="+dto.getSeq())를 돌려줘 브라우저가 새 GET 요청을 보내게 합니다. 그래야 새로고침해도 저장이 반복되지 않습니다(32일차의 그 원리를 스프링 문법으로). 실패 시 "redirect:error.jsp"는 error.jsp가 /WEB-INF/views 안에 있어서 브라우저가 직접 열 수 없으므로 열리지 않습니다(파일 위치로 따진 결과). → 🖥️ 50. forward와 redirect
④ 삭제는 POST로, 날짜는 fmt:formatDate로
수업 주석처럼 삭제는 POST를 권장합니다 — mulDel.do?seq=10이 주소창·기록에 남거나 링크 하나로 실행되면 위험하기 때문입니다. 목록의 글삭제는 폼 POST(action="mulDel.do")로 보내지만, 이날 mulDel.do는 GET·POST를 둘 다 받도록 열려 있습니다. 목록의 작성일은 JSTL fmt 태그 <fmt:formatDate value="${dto.regDate}" pattern="yyyy년 MM월 dd일"/>로 사람이 읽기 좋은 형식으로 바꿨습니다.
JAVABoardController.java — 글쓰기 · 상세 · 수정 (9월 28일 추가)
JAVABoardController.java — 다중 삭제 · GET/POST 나누기 연습 · home.do
//url패턴은 같으면서 로직을 다르게 처리할 경우
@GetMapping("/mulDel2.do")
public String mulDelGet(){
return ""; // ← 빈 연습용 자리
}
@PostMapping("/mulDel2.do")
public String mulDelPost() {
return "";
}
//삭제하는 기능은 post방식을 권장함
// mulDel.do?seq=10 주소창에 노출되면 위험하다.
//Spring6 에서는 파라미터 받을때 @RequestParam 명시하기
@RequestMapping(value = "/mulDel.do", method = {RequestMethod.GET,RequestMethod.POST})
public String mulDel(@RequestParam("seq")String[] seq) { // ← 이름을 적었으니 컴파일 옵션과 무관
boolean isS=hkService.mulDel(seq);
if(isS) {
return "redirect:boardlist.do";
}else {
return "redirect:error.jsp";
}
}
@RequestMapping(value = "/home.do", method = RequestMethod.GET )
public String home(Model model,
String param,//"param"이라는 이름의 값이 넘어오면 // ← 애너테이션 없음: 이름을 못 읽으면 예외
HkDto dto,// dto에 맴버필드명과 일치하는 값이 넘어오면
HttpServletRequest request) {
model.addAttribute("param", "파람");
return "home";//forward
}
실행 결과home(…)의 String param을 스프링 매개변수 해석기에 넣어 본 결과 먼저 예측 → 펼쳐서 확인
-parameters 없음 → IllegalArgumentException: Name for argument of type [java.lang.String] not specified,
and parameter name information not available via reflection.
Ensure that the compiler uses the '-parameters' flag.
-parameters 있음 → 매개변수 이름 model, param, dto, request / param = null (정상)
07 README에 남긴 재현 결과입니다(DB·서버 없이 스프링의 RequestParamMethodArgumentResolver만 호출). 해결은 @RequestParam(value = "param", required = false)처럼 이름을 적거나, maven-compiler-plugin에 <parameters>true</parameters>를 넣는 것입니다. 이름을 적은 mulDel과 커맨드 객체(HkDto)는 영향이 없습니다.
메서드마다 @RequestMapping("/boardInsert.do") 같은 주소표를 붙이면 스프링이 알맞은 메서드를 찾아 호출한다 — URL을 직접 잘라 if문으로 분기할 필요가 없다.
폼 입력칸의 name이 DTO 필드 이름과 같으면 HkDto 매개변수 하나로 값이 한꺼번에 채워진다(커맨드 객체). 같은 이름 여러 값은 @RequestParam("seq") String[].
단순 타입 매개변수에는 @RequestParam("이름")처럼 이름을 적어 두면 컴파일 옵션(-parameters)과 상관없이 동작한다.
저장·수정·삭제 뒤에는 "redirect:boardlist.do"로 새 GET 요청을 보내게 해서, 새로고침해도 같은 작업이 반복되지 않게 한다. 조회 결과를 보여 줄 때는 뷰 이름을 돌려준다(forward).
redirect는 브라우저가 직접 주소를 여는 것이라 /WEB-INF/views 안의 JSP(예: error.jsp)로는 redirect할 수 없다 — 이 날 코드의 redirect:error.jsp는 그래서 열리지 않는다. mulDel2.do의 GET·POST 메서드는 빈 연습용 자리다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
글을 저장한 뒤 return "boardlist" 가 아니라 "redirect:boardlist.do" 인 이유는?
forward로 목록을 보여 주면 주소창이 여전히 boardInsert.do(POST)라서 새로고침하면 같은 글이 또 저장됩니다. redirect는 브라우저가 목록 주소로 새 GET 요청을 보내게 하므로 새로고침해도 목록만 다시 읽습니다.
home.do 의 String param 에 @RequestParam 을 붙이지 않으면 이 PC에서 어떻게 되나?
이 PC의 Eclipse·Maven 설정은 -parameters 옵션 없이 컴파일합니다. 그러면 Spring 6.1은 매개변수 이름을 알 수 없어 IllegalArgumentException을 냅니다. @RequestParam("param")으로 이름을 적어 주거나 컴파일 옵션을 켜야 합니다.
글쓰기 폼의 제목 입력칸 name을 "subject"로 바꾸면 boardInsert(HkDto dto)에서 무슨 일이 생길까?
예외는 나지 않지만 dto.getTitle()이 null입니다. 커맨드 객체는 요청 값의 이름과 같은 setter(setTitle)를 찾아 채우는데, subject에 맞는 setter가 없으니 그 값은 그냥 버려집니다. 그 상태로 INSERT하면 DB의 NOT NULL 제약에 걸려 실패할 수 있습니다 — 폼의 name과 DTO 필드 이름을 맞추는 것이 바인딩의 전부입니다.
💼 실무·코딩테스트에서는실무 스프링 코드는 대부분 @GetMapping·@PostMapping처럼 방식을 드러내는 축약형을 쓰고, 단순 값은 @RequestParam·@PathVariable에 이름을 명시하는 것이 관례입니다(오늘 겪은 -parameters 문제를 원천 차단). "저장 후 redirect"는 PRG 패턴, "삭제·변경은 GET으로 받지 않는다"는 HTTP 메서드의 의미(안전한 메서드)로 면접에서 자주 묻습니다. 폼 → DTO 바인딩이 이해되면 나중에 JSON을 받는 @RequestBody도 같은 감각으로 읽힙니다.
확인함 — 오늘 수정한 Java 6개 파일이 JDK 21로 컴파일된다(-parameters가 있을 때와 없을 때 모두).
확인함 — home.do의 String param은 이 PC의 빌드 설정에서 이름을 읽지 못해 예외가 난다. 스프링의 매개변수 해석기에 직접 넣어 재현했다(자세히).
확인하지 않음 — 톰캣에서 글쓰기·상세·수정·삭제를 실제 DB로 눌러 본 기록은 이번 정리에 없다.
redirect:error.jsp가 열리지 않는다는 판단은 파일 위치로 따진 결과다.
39일차
2026-09-29 · 답변형 게시판 시작 — 08 프로젝트와 로그
답변형 게시판refer · step · depthcomponent-scanSLF4J · Logback로그 레벨
한 줄 요약07을 바탕으로 답글을 달 수 있는 게시판(08)을 새로 시작했다. DTO에 답글 위치를 나타내는 refer·step·depth를 넣고, System.out 대신 로거(SLF4J)로 기록을 남기기 시작했다.
쉽게 말하면게시판 틀은 07과 같지만, 이번 글에는 "몇 번째 글 묶음의, 몇 번째 줄, 몇 칸 들여쓰기"라는 자리표가 붙습니다. 로그는 업무 일지예요 — System.out이 아무 데나 메모하는 것이라면, 로거는 "정보(info)", "자세한 진단(debug)"처럼 등급을 매겨 기록하고, 설정만 바꿔 어느 등급까지 남길지 고를 수 있습니다.
① 07을 복사해 08로 — 패키지가 바뀌면 스캔 범위도
07을 복사해 08_answerboard_springMVC를 만들고 패키지를 com.hk.board에서 com.hk.ansboard로 바꿨습니다. 이때 component-scan의 base-package도 같이 바꿔야 Controller·Service·DAO가 빈으로 등록됩니다 — 스캔 범위 밖의 클래스는 @Controller를 붙여도 스프링이 찾지 못합니다(36일차). Mapper의 namespace와 DAO의 namespace 문자열도 com.hk.ansboard.dao로 맞췄습니다. 클래스 이름도 AnsController·AnsService·AnsDao·AnsDto로 바뀌었습니다. → 🖥️ 52. 07에서 08로
② pom.xml — 로깅 · AOP · 트랜잭션 라이브러리 추가
pom.xml에 로깅(slf4j-api 2.0.13, jcl-over-slf4j, logback-classic 1.5.6), AOP(AspectJ 1.9.22.1의 aspectjrt·aspectjweaver), spring-tx를 추가하고, MariaDB 드라이버(2.6.2 → 3.3.3)와 MyBatis(3.5.6 → 3.5.16) 버전을 올렸습니다. AOP·트랜잭션 라이브러리는 이날 쓰지 않고 41~42일차를 위한 준비입니다.
③ AnsDto — 답글을 한 테이블에 줄 세우는 자리표
답변형 게시판은 원글과 답글을 같은 테이블에 저장하고, 세 숫자로 위치를 표현합니다. refer = 어느 글 묶음인지(원글과 그 답글들은 같은 번호), step = 묶음 안에서 몇 번째 줄인지(원글은 0), depth = 몇 칸 들여쓸지(원글 0, 답글 1, 답글의 답글 2). 여기에 readCount(조회수)와 delflag(삭제 표시)까지 다섯 필드가 07의 HkDto보다 늘었습니다. 이 숫자들로 목록을 정렬하고 들여쓰는 일은 40·41일차에 합니다. → 🖥️ 54. refer · step · depth
④ 로그 — SLF4J는 창구, 실제로 찍는 건 Logback
Controller에서 private static final Logger logger = LoggerFactory.getLogger(AnsController.class);로 로거를 만들고 logger.info("HOME페이지로 이동"), logger.debug("Working Directory:{}", …)처럼 레벨을 골라 씁니다. {} 자리에 뒤의 값이 들어갑니다. 그런데 SLF4J는 "로그를 남기는 창구(인터페이스)"일 뿐이고, 실제로 출력하는 구현체는 클래스패스에 있는 라이브러리가 정합니다 — 08의 pom에는 logback-classic이 있으므로 Logback이 찍습니다. 수업에서 넣은 설정 파일 log4j.xml은 Log4j 1 형식이라 Logback이 읽지 않고, Logback은 자기 설정(logback.xml)이 없으면 기본값(콘솔에 전체 DEBUG)으로 동작합니다. 그래서 log4j.xml에 적은 날짜별 로그 파일도 생기지 않습니다. → 🖥️ 53. 로그와 log4j.xml
JAVAAnsDto.java — 07의 HkDto에서 늘어난 다섯 필드 (08_answerboard_springMVC)
package com.hk.ansboard.dtos;
public class AnsDto {
private int seq;
private String id;
private String title;
private String content;
private Date regDate;
private int refer; // ← 글 묶음 번호 (원글과 답글이 같은 값)
private int step; // ← 묶음 안 순서 (원글 0, 위에서부터 1, 2, …)
private int depth; // ← 들여쓰기 칸 수 (원글 0, 답글 1, 답글의 답글 2)
private int readCount; // ← 조회수
private String delflag; // ← 삭제 표시 ('N' / 'Y')
… (생성자 · getter/setter · toString)
}
JAVAAnsController.java — 로거 선언과 레벨별 기록 (핵심 부분)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory; // ← SLF4J: 창구만 import
@Controller
public class AnsController {
//log 출력을 위한 선언: slf4j(로그출력할 준비작업), log4j(실제 출력 작업)
private static final Logger logger=
LoggerFactory.getLogger(AnsController.class); // ← 로거 이름 = 클래스 이름
@RequestMapping(value = "/home.do",
method = RequestMethod.GET)
public String home() {
logger.info("HOME페이지로 이동"); // ← 정보 레벨
logger.debug("Working Directory:{}", System.getProperty("user.dir")); // ← {} 자리에 값
return "home";
}
…
}
동작 설명refer · step · depth로 본 글 묶음 하나 (원글 + 답글 2개 + 답글의 답글) 먼저 예측 → 펼쳐서 확인
제목 refer step depth
원글: 스프링 질문 5 0 0
ㄴ 답글 A 5 1 1
ㄴ A에 단 답글 5 2 2
ㄴ 답글 B 5 3 1
자리표의 의미를 보여 주는 예시입니다(실제 번호는 글을 쓴 순서에 따라 정해짐). 같은 묶음은 refer가 같고, step 순서대로 위에서 아래로 놓으며, depth만큼 들여쓰면 위처럼 "누가 누구의 답글인지"가 보입니다. 새 답글이 어느 step에 끼어드는지는 41일차에 다룹니다.
39일차에 배운 것 — 핵심 정리
답변형 게시판의 글에는 자리표 세 개가 붙는다 — refer(같은 글 묶음 번호), step(묶음 안에서 몇 번째 줄), depth(몇 칸 들여쓸지).
프로젝트를 복사해 패키지 이름을 바꾸면 component-scan의 범위도 같이 바꿔야 Controller·Service·DAO가 빈으로 등록된다.
LoggerFactory.getLogger(...)의 SLF4J는 "로그 창구"일 뿐이고, 실제로 찍는 것은 클래스패스에 있는 구현체(08에서는 Logback)다 — 그래서 설정 파일도 구현체에 맞는 logback.xml이어야 하고, 지금 넣은 log4j.xml은 읽히지 않는다(설정이 없으면 Logback 기본값: 콘솔에 전체 DEBUG).
logger.info·logger.debug처럼 레벨을 나눠 두면 설정만 바꿔 출력 여부를 조절할 수 있다 — System.out과 가장 큰 차이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
코드는 LoggerFactory(SLF4J)를 쓰는데 실제로 글자를 찍는 건 누구인가?
SLF4J는 "로그를 남기는 창구"일 뿐이고, 실제로 출력하는 구현체는 클래스패스에 있는 라이브러리가 정합니다. 08의 pom에는 logback-classic이 있으므로 Logback이 출력합니다. 그래서 설정 파일도 Logback의 logback.xml이어야 하고, 지금 있는 log4j.xml은 읽히지 않습니다.
패키지를 com.hk.ansboard로 바꾸고 servlet-context.xml의 스캔은 com.hk.board로 그대로 두면?
스캔 범위에 08의 클래스가 하나도 없어 Controller가 빈으로 등록되지 않고, /home.do·/boardList.do 같은 요청을 맡을 메서드가 없어 404가 납니다. 컴파일은 아무 문제 없이 되기 때문에 놓치기 쉬운 곳입니다.
logger.debug("값:" + 큰객체) 대신 logger.debug("값:{}", 큰객체)로 쓰는 이유는?
+로 이어 붙이면 debug가 출력되지 않는 설정이어도 문자열을 먼저 만들어 버립니다. {} 방식은 그 레벨이 켜져 있을 때만 값을 끼워 넣어 문자열을 만들므로, 운영에서 debug를 꺼 두면 그 비용이 들지 않습니다.
💼 실무·코딩테스트에서는실무 코드에서 System.out.println은 거의 쓰지 않고 로거를 씁니다 — 레벨(TRACE·DEBUG·INFO·WARN·ERROR)로 거르고, 시간·클래스 이름을 붙이고, 파일로 남기고, 운영에서는 INFO 이상만 남기는 식입니다. "SLF4J는 파사드(창구), Logback·Log4j2는 구현체"라는 구조는 면접에서도 나오고, 오늘처럼 설정 파일이 왜 안 먹는지를 따질 때 바로 쓰입니다. 답변형 게시판의 refer·step·depth는 "계층형 댓글을 테이블 하나에 어떻게 저장할까"라는 실무 설계 문제의 한 가지 답입니다.
한 줄 요약08에 공통 머리·꼬리 화면과 Bootstrap을 붙이고, 10개씩 끊어 보는 목록(ROW_NUMBER)·글쓰기(refer = MAX+1)·상세(조회수)·수정·논리 삭제(delflag='Y')를 연결했다.
쉽게 말하면07에서 만든 CRUD를 "답글을 달 수 있는 판"으로 옮겨 다시 세운 날입니다. 이번엔 글을 진짜로 지우지 않고 "삭제됨" 딱지만 붙입니다 — 원글을 지워 버리면 그 아래 답글들이 갈 곳을 잃으니까요. 조회수는 목록에서 눌러 들어올 때만 하나 올리고, 새로고침으로는 안 오르게 했습니다.
① 목록 — 묶음 순서로 줄 세우고 10개씩 자르기
답변형 목록의 정렬 기준은 ORDER BY refer DESC, step ASC입니다 — 최신 묶음이 위로(refer 큰 순), 묶음 안에서는 원글 → 답글 순(step 작은 순). 그 순서대로 ROW_NUMBER() OVER(…)로 1, 2, 3 … 행 번호(rn)를 매기고, 바깥 쿼리에서 ceil(rn/10) = #{pnum}인 행만 꺼냅니다. rn 1~10은 ceil이 1 → 1쪽, 11~20은 2 → 2쪽이 되는 식이죠. 화면은 depth만큼 공백을 넣어 들여쓰고, delflag가 'Y'면 제목 대신 "---삭제된 글입니다.---"를 보여 줍니다. 페이지 번호 링크는 이날 자리만 있었고 41일차에 채웠습니다. → 🖥️ 54. refer·step·depth와 페이징
② 새 글 — 새 묶음 번호를 하나 만든다
원글은 새 묶음의 맨 위이므로 refer는 지금까지의 가장 큰 refer + 1((SELECT nvl(MAX(refer),0)+1 FROM answerboard) — 글이 하나도 없을 땐 nvl이 0으로 바꿔 1이 됨), step = 0, depth = 0, 조회수 0, delflag = 'N'으로 저장합니다. 이 값들 덕분에 새 글이 refer DESC 정렬에서 가장 위에 옵니다.
③ 상세 — review=y일 때만 조회수를 올리고 redirect
목록의 제목 링크에는 review=y가 붙어 있습니다. boardDetail.do는 review가 "y"이면 조회수만 올리고(readcount = readcount+1), review없는 상세 주소로 redirect합니다. 두 번째 요청에서는 review가 없으니 조회수를 건드리지 않고 글만 보여 줍니다. 그래서 상세 화면에서 새로고침하거나 수정 후 돌아와도 조회수가 다시 오르지 않습니다. 대신 한 번 클릭에 요청이 두 번 오가는 비용이 있다는 점을 수업 주석이 짚었습니다. → 🖥️ 55. 논리 삭제와 조회수
④ 논리 삭제와 공통 화면
삭제 SQL은 DELETE가 아니라 UPDATE answerboard SET delflag='Y' WHERE seq IN (…)입니다(논리 삭제) — 행이 남아 있으니 답글 묶음이 깨지지 않고 기록도 남습니다. 그래서 DAO도 sqlSession.update(…)로 부릅니다. 상세 화면의 삭제 버튼은 링크(GET) 대신 숨은 폼에 번호를 채워 POST로 제출합니다. 화면 공통 부분은 header.jsp·footer.jsp로 떼어 각 화면에서 <jsp:include page="header.jsp"/>로 불러오고, Bootstrap 5를 CDN으로 붙였습니다. 한 가지 남은 문제: 실패 시 return "error.jsp"는 파일이 아니라 뷰 이름으로 해석돼 /WEB-INF/views/error.jsp.jsp를 찾습니다(08에는 error.jsp 자체도 아직 없음). → 🖥️ 56. 공통 화면과 남은 것
SQLBoardMapper.xml — 목록 · 새 글 · 논리 삭제 · 조회수 (08)
<select id="boardList" parameterType="Map" resultType="AnsDto" >
SELECT rn, seq, id, title, content, regdate, refer, step, depth, readcount, delflag
FROM (
SELECT ROW_NUMBER() OVER(ORDER BY refer DESC, step ASC) AS rn, -- ← 묶음 순서대로 번호
seq, id, title, content, regdate, refer, step, depth, readcount, delflag
FROM answerboard
) a
WHERE ceil(rn/10) = #{pnum} -- ← 10개씩: 1~10 → 1쪽
</select>
<insert id="boardInsert" parameterType="AnsDto">
INSERT INTO answerboard
VALUES(NULL,#{id},#{title},#{content},SYSDATE(),
(SELECT nvl(MAX(refer),0)+1 FROM answerboard) -- ← 새 묶음 번호
,0,0,0,'N') -- ← step·depth·조회수 0, 삭제 안 됨
</insert>
<!-- 동적쿼리 사용시 파라미터는 map에 담아서 전달하자 -->
<update id="mulDel" parameterType="Map">
update answerboard set delflag='Y' -- ← DELETE가 아니라 표시만
where seq in
<foreach collection="seqs" item="seq" open="(" close=")" separator=",">
#{seq}
</foreach>
</update>
<!-- 조회수 올리기 -->
<update id="readCount" parameterType="Integer">
update answerboard set readcount = readcount+1
where seq = #{seq}
</update>
답변형 목록은 ORDER BY refer DESC, step ASC(최신 묶음 먼저, 묶음 안에서는 원글 → 답글 순)로 정렬하고, ROW_NUMBER()로 매긴 행 번호를 10으로 나눠 올림한 값(ceil(rn/10))이 페이지 번호와 같은 행만 꺼낸다.
새 원글은 refer = MAX(refer)+1(새 묶음), step = depth = 0(묶음의 맨 위, 들여쓰기 없음)으로 저장한다.
논리 삭제 — DELETE 대신 delflag='Y'로 표시만 바꿔, 답글 묶음이 깨지지 않고 기록도 남는다.
조회수는 목록에서 review=y로 들어올 때만 올리고 review 없는 주소로 redirect해, 새로고침으로 조회수가 다시 오르지 않게 한다.
Controller가 return "error.jsp"를 돌려주면 파일이 아니라 뷰 이름으로 해석되어 /WEB-INF/views/error.jsp.jsp를 찾는다 — 뷰 이름에는 확장자를 붙이지 않는다(08에는 error.jsp 자체도 아직 없다). 페이지 번호 링크·답글 달기는 다음 날로 남았다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
9/28의 home.do 문제(-parameters)가 08에서는 왜 안 생기나?
08 Controller의 단순 타입 매개변수가 모두 @RequestParam("seq")·@RequestParam(value="pnum", defaultValue="1")처럼 이름을 적고 있기 때문입니다. 이름을 적으면 컴파일 옵션과 상관없이 스프링이 어떤 요청 값을 넣을지 압니다.
상세보기 링크에 review=y 를 붙이는 이유는?
조회수는 "목록에서 눌러 들어왔을 때"만 올려야 합니다. review=y일 때만 조회수를 올리고 review 없는 주소로 redirect 하므로, 상세 화면에서 새로고침하거나 수정 후 돌아와도 조회수가 다시 오르지 않습니다.
원글을 DELETE로 진짜 지우면 그 아래 답글들은 어떻게 보일까?
답글 행은 그대로 남아 같은 refer 묶음에 원글 없이 놓입니다. depth만큼 들여쓰인 채 "무엇에 대한 답글인지" 알 수 없는 줄이 되죠. 논리 삭제는 원글 자리를 "삭제된 글입니다"로 남겨 두어 묶음의 모양을 지킵니다.
💼 실무·코딩테스트에서는논리 삭제(soft delete)는 실무에서 아주 흔합니다 — 실수로 지운 데이터를 복구하고, 누가 언제 지웠는지 감사 기록을 남기기 위해서입니다(대신 조회할 때마다 "삭제 안 된 것만" 거르는 조건을 잊지 말아야 함). 페이징은 LIMIT … OFFSET … 방식도 많이 쓰니 ROW_NUMBER 방식과 비교해 두면 좋고, ROW_NUMBER() OVER(ORDER BY …) 같은 윈도 함수는 SQL 코딩테스트("그룹별 N등", "순위 매기기")에 자주 나옵니다.
확인함 — Java 소스 컴파일, 그리고 9/28에 문제였던 이름 없는 단순 타입 매개변수가 08 Controller에는 없다.
확인하지 않음 — 톰캣에서 실제 DB로 목록·글쓰기·상세·수정·삭제를 눌러 본 기록은 이번 정리에 없다.
error.jsp 관련 판단은 파일로 따진 결과다.
41일차
2026-10-01 · 페이지 번호와 답글 달기
Paging 유틸pnum 유지ceil(count/10)답글 replyUpdate · replyInsert스캔 범위 나누기
한 줄 요약9/30에 자리만 있던 페이지 번호와 답글 달기를 채웠다. 페이지 번호는 Paging 유틸이 5개씩 묶어 계산하고, 모든 화면이 지금 몇 쪽인지(pnum)를 들고 다닌다. 답글은 "아래 글들 한 칸씩 밀기 → 빈자리에 넣기" 두 쿼리로 만든다.
쉽게 말하면책 목차처럼 쪽 번호를 "1 2 3 4 5 다음"으로 보여 주고, 8쪽에서 글을 열었으면 목록으로 돌아올 때도 8쪽으로 돌아오게 했습니다. 답글은 줄 서 있는 사람들 사이에 끼어들 때 뒷사람들이 한 걸음씩 물러나 자리를 먼저 만들고, 그 빈자리에 들어가는 방식입니다.
① 총 페이지 수와 Paging 유틸 — 번호 묶음 계산
Mapper에 getPcount(select ceil(count(*)/10) from answerboard)를 더해 총 페이지 수를 구합니다. 글이 23개면 2.3을 올림해 3쪽 — 나눠떨어지지 않는 나머지 글을 위한 한 쪽이 더 생깁니다. util/Paging.java의 pagingValue(총페이지수, 현재쪽, 5)는 현재 쪽이 속한 5개 묶음의 끝 번호를 ((pnum-1)/5+1)*5로 구하고(정수 나눗셈), 그것으로 시작 번호(끝-4)·끝 번호(총 페이지 수를 넘지 않게)·이전(앞 묶음의 끝)·다음(다음 묶음의 첫 번호)을 계산해 Map으로 돌려줍니다. 목록 화면은 startPage부터 endPage까지 <c:forEach>로 번호 링크를 찍습니다. → 🖥️ 57. 페이지 번호 — Paging과 pnum
② pnum 들고 다니기 — 보던 쪽으로 돌아가려면
HTTP는 앞 요청을 기억하지 않으므로, "지금 8쪽을 보고 있었다"는 사실을 요청마다 직접 실어 날라야 합니다. 그래서 index.jsp의 게시판 링크는 boardList.do?pnum=1로 시작하고, 글쓰기 폼·상세·수정·삭제가 모두 pnum을 주소(?pnum=)나 숨은 입력칸(<input type="hidden" name="pnum">)으로 넘겨받습니다. Controller는 @RequestParam("pnum")으로 받아 Model에 다시 담거나 "redirect:boardList.do?pnum="+pnum처럼 돌아갈 주소에 붙입니다. 목록은 @RequestParam(value="pnum", defaultValue="1")이라 pnum 없이 와도 1쪽입니다.
③ 답글 — 쿼리 두 개: 자리 만들기 → 끼워 넣기
상세 화면 아래에 답글 폼을 붙이고, 부모 글 번호를 숨은 입력칸(seq)으로 보냅니다. 저장은 두 단계입니다. ① replyUpdate: 부모와 같은 묶음(refer)에서 부모보다 아래(step이 큰) 글들의 step을 1씩 밀어 부모 바로 아래 자리를 비웁니다. ② replyInsert: 부모의 refer, 부모 step+1, 부모 depth+1로 새 글을 넣습니다. 그래서 같은 글에 단 답글은 나중에 단 것이 위에 옵니다. 두 쿼리는 한 묶음이어야 해서(①만 되고 ②가 실패하면 step만 밀린 채 남음) 다음 날 트랜잭션이 필요해집니다. Mapper의 replyUpdate는 SQL을 <![CDATA[ … ]]>로 감쌌는데, 비교 기호가 XML 문법과 섞이지 않게 하는 습관입니다(>는 그대로 써도 되지만 <는 XML 태그 시작으로 읽혀 CDATA나 <가 필요). → 🖥️ 58. 답글 달기
④ 스캔 범위를 controller로 좁히기 — 42일차 준비
servlet-context.xml의 component-scan을 com.hk.ansboard 전체에서 com.hk.ansboard.controller로 좁혔습니다. 화면 쪽 설정(자식 컨텍스트)은 Controller만, 서비스·DAO는 root-context(부모 컨텍스트)가 맡도록 나누는 첫걸음으로, 트랜잭션이 제대로 걸리게 하기 위한 준비입니다(이유는 42일차).
JAVAutil/Paging.java — 5개씩 묶은 페이지 번호 계산 (수업 원본, 주석 일부 생략)
public class Paging {
//prePageNum:전페이지의 마지막번호 1 2 3 4 5 < 6 7 8 9 10 > 11 12 13
//nextPageNum:다음페이지의 첫번째 번호
//startPage:시작페이지 번호
//endPage:끝나는 페이지 번호
//pcount:총페이지수 pNum:현재 보여줄 페이지 번호 pageRange:한번에 보여줄 페이지 범위
public static Map<String, Integer> pagingValue(int pcount,String pNum,int pageRange){
Map<String, Integer> map=new HashMap<String, Integer>();
int pNumber=Integer.parseInt(pNum);
//1234(5) 6789(10) : 페이지 번호를 받아 해당 페이지의 마지막 페이지 번호를 구함
int pageEndNum=((pNumber-1)/pageRange+1)==1?pageRange:((pNumber-1)/pageRange+1)*pageRange; // ← 묶음의 끝
int prePageNum=pageEndNum-pageRange==0?1:pageEndNum-pageRange; // ← 앞 묶음의 끝(첫 묶음이면 1)
int nextPageNum=pageEndNum>=pcount?pcount:pageEndNum+1; // ← 다음 묶음의 첫 번호
int startPage=pageEndNum-(pageRange-1);//현재페이지번호가 8일경우 10-(5-1)= 6
int endPage=pageEndNum>pcount?pcount:pageEndNum; // ← 총 페이지 수를 넘지 않게
map.put("prePageNum", prePageNum);
map.put("nextPageNum", nextPageNum);
map.put("startPage", startPage);
map.put("endPage", endPage);
return map;
}
}
실행 결과총 페이지 14일 때 pagingValue(14, pnum, 5) 먼저 예측 → 펼쳐서 확인
코드의 식에 값을 넣어 계산한 결과입니다. 8쪽이면 6~10이 보이고 "이전"은 5, "다음"은 11. 12쪽이면 묶음 끝이 15지만 총 페이지가 14라 11~14까지만 찍히고, "다음"도 14에 머뭅니다. 1쪽(첫 묶음)에서 "이전"은 0이 아니라 1이 됩니다.
SQLBoardMapper.xml — 총 페이지 수 · 답글 두 쿼리 (08)
<!-- page 개수 구하기 -->
<select id="getPcount" resultType="int">
select ceil(count(*)/10) -- ← 23개면 3쪽
from answerboard
</select>
<!-- 답글달기 -->
<update id="replyUpdate" parameterType="AnsDto">
<![CDATA[
UPDATE answerboard SET step = step+1 -- ① 아래 글들을 한 칸씩 밀기
WHERE refer=(SELECT refer FROM answerboard WHERE seq=#{seq})
AND step > (SELECT step FROM answerboard WHERE seq=#{seq}) -- ← 부모보다 아래만
]]>
</update>
<insert id="replyInsert" parameterType="AnsDto">
INSERT INTO answerboard
VALUES(NULL,#{id},#{title},#{content},SYSDATE(),
(SELECT refer FROM answerboard WHERE seq=#{seq}), -- ② 부모와 같은 묶음
(SELECT step FROM answerboard WHERE seq=#{seq})+1, -- 부모 바로 아래
(SELECT depth FROM answerboard WHERE seq=#{seq})+1, 0, 'N') -- 한 칸 더 들여쓰기
</insert>
동작 설명원글(step 0)에 답글 A(step 1)가 있을 때, 원글에 답글 B를 달면 먼저 예측 → 펼쳐서 확인
처음 ① replyUpdate ② replyInsert
원글 step 0 step 0 step 0
답글 A step 1 → step 2 (밀림) step 2
답글 B - - → step 1, depth 1 (새로 들어감)
목록 순서(step ASC): 원글 → 답글 B → 답글 A
①에서 부모(step 0)보다 아래인 답글 A가 2로 밀려 step 1 자리가 비고, ②에서 B가 그 자리(부모 step + 1)에 들어갑니다. 그래서 나중에 단 답글 B가 A보다 위에 보입니다. 같은 묶음 안에서 step이 겹치지 않는 것이 이 방식의 핵심입니다.
JSPboardDetail.jsp — 답글 폼과 삭제용 숨은 폼, pnum 싣기
<form id="deleteForm" action="mulDel.do" method="post">
<input type="hidden" id="deleteSeq" name="seq" value=""/>
<input type="hidden" name="pnum" value="${pnum}"/> <!-- ← 지운 뒤 보던 쪽으로 -->
</form>
<div id="replyForm">
<h1>답글 작성하기</h1>
<form action="boardReply.do" method="post">
<!-- 부모글에 seq를 전달한다. -->
<input type="hidden" name="seq" value="${dto.seq}"/> <!-- ← 부모 글 번호 -->
<input type="hidden" name="pnum" value="${pnum}"/>
… (작성자 · 제목 · 내용 입력칸, 답글등록 버튼)
</form>
</div>
41일차에 배운 것 — 핵심 정리
페이지 번호 묶음은 현재 쪽(pnum)과 묶음 크기(5)로 계산한다 — 묶음의 끝 번호 = ((pnum-1)/5+1)*5, 시작 번호 = 끝 - 4, 마지막 묶음의 끝은 총 페이지 수를 넘지 않게 자른다.
총 페이지 수는 ceil(count(*)/10) — 글이 10개 단위로 나눠떨어지지 않아도 남은 글을 위한 한 쪽이 더 생긴다.
상세·수정·삭제 화면이 pnum을 주소나 숨은 입력칸으로 계속 들고 다녀야, 작업이 끝난 뒤 보던 쪽 목록으로 돌아갈 수 있다.
답글은 쿼리 두 개로 저장한다 — ① replyUpdate: 같은 묶음(refer)에서 부모보다 아래(step이 큰) 글들의 step을 1씩 밀어 자리 만들기 → ② replyInsert: 부모의 refer, 부모 step+1, 부모 depth+1로 끼워 넣기. 두 쿼리가 한 묶음이라 다음 날 트랜잭션이 필요해진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
답글을 저장할 때 INSERT보다 UPDATE를 먼저 하는 이유는?
새 답글은 부모 글 바로 아래(부모 step + 1)에 들어가야 합니다. 그 자리에 이미 다른 답글이 있을 수 있으니, 같은 묶음(refer)에서 부모보다 아래에 있는 글들의 step을 먼저 1씩 밀어 자리를 비웁니다. 그래서 같은 글에 달린 답글은 나중에 단 것이 위에 옵니다.
5개씩 보여 줄 때 8쪽을 보고 있으면 번호는 몇부터 몇까지 나오나?
6~10입니다. 8은 두 번째 묶음(6~10)에 속하고, "이전"은 앞 묶음의 끝인 5, "다음"은 다음 묶음의 첫 번호인 11을 가리킵니다(총 페이지가 11 이상일 때).
replyUpdate만 성공하고 replyInsert가 실패하면 목록은 어떻게 될까?
답글은 들어가지 않았는데 아래 글들의 step만 1씩 밀린 채 남습니다. 묶음 안에 빈 step 번호가 생기는 잘못된 데이터죠. 순서는 크게 틀어지지 않아 눈에 잘 안 띄지만, 이런 일이 쌓이면 데이터를 믿을 수 없게 됩니다. 그래서 두 쿼리를 "둘 다 되거나 둘 다 안 되거나"로 묶는 트랜잭션을 42일차에 겁니다.
💼 실무·코딩테스트에서는페이징 계산은 "올림 나눗셈" 문제입니다 — 정수만으로는 (전체 + 10 - 1) / 10처럼 쓰면 ceil 없이 올림이 되고, 코딩테스트에서 "몇 묶음이 필요한가" 유형으로 자주 나옵니다. 실무 게시판은 현재 페이지·정렬·검색어 같은 상태를 주소(쿼리스트링)에 실어 공유·새로고침해도 같은 화면이 나오게 하는데, 오늘의 pnum 들고 다니기가 그 원리입니다. 또 오늘 답글처럼 여러 쿼리가 하나의 업무인 곳을 찾아내는 눈이 트랜잭션 설계의 출발점입니다.
한 줄 요약쿼리 두 개가 한 묶음이어야 하는 답글 저장에 트랜잭션(@Transactional)을 걸고, DAO 메서드마다 로그를 남기는 일을 AOP로 DAO 바깥에 뺐다. Controller는 생성자 주입으로 바꾸고, 페이징 조립은 Service로 옮겼다.
쉽게 말하면트랜잭션은 송금처럼 "빼기와 넣기가 둘 다 되거나 둘 다 안 되거나"를 보장합니다. AOP는 방마다 출입 기록기를 다는 대신 복도 입구에 하나만 달아 두는 것이에요 — DAO 코드는 한 줄도 안 고쳤는데 모든 DAO 메서드 앞뒤에 로그가 찍힙니다. 둘 다 스프링이 진짜 객체 앞에 대리인(프록시)을 세워 두고, 호출이 지나갈 때 일을 끼워 넣는 같은 원리로 동작합니다.
① @Transactional — 답글 두 쿼리를 한 묶음으로
root-context.xml에 DataSourceTransactionManager(커넥션 풀로 commit·rollback을 실제로 하는 관리자)를 등록하고, <tx:annotation-driven transaction-manager="transactionManager" proxy-target-class="true"/>로 "@Transactional이 붙은 메서드를 찾아 트랜잭션을 걸어라"를 켰습니다. 그리고 AnsService.boardReply에 @Transactional(propagation = Propagation.REQUIRED)를 붙였습니다. 이제 메서드가 정상 종료되면 commit, 실행 중 RuntimeException(또는 Error)이 밖으로 나가면 rollback됩니다 — replyUpdate만 되고 replyInsert가 실패하는 일이 사라집니다. 수업 코드에는 TransactionSynchronizationManager.isActualTransactionActive()로 "트랜젝션 활성 상태"를 로그로 찍고, 일부러 RuntimeException을 던지는 롤백 테스트 줄(주석 처리)이 남아 있습니다. 33일차에 JDBC로 직접 쓰던 setAutoCommit(false)·commit()·rollback()을 애너테이션 하나가 대신하는 셈입니다. → 🖥️ 59. 트랜잭션과 컨텍스트 나누기
② 프록시와 컨텍스트 나누기 — 왜 스캔 범위를 쪼갰나
@Transactional은 스프링이 AnsService를 감싼 대리 객체(프록시)를 만들어, 메서드 호출 앞뒤에 "트랜잭션 시작 → commit/rollback"을 끼워 넣는 방식입니다. 그래서 Controller가 프록시를 주입받아 호출할 때만 걸립니다. 트랜잭션 설정은 root-context(부모)에 있으므로, 서비스·DAO도 root-context가 스캔(com.hk.ansboard.service,com.hk.ansboard.dao)하고, servlet-context(자식)는 controller만 스캔하게 나눴습니다(41일차에 좁힌 것). 자식이 전체를 다시 스캔하면 트랜잭션 설정이 없는 AnsService가 하나 더 만들어지고 Controller는 가까운 자식 쪽 것을 받아 트랜잭션이 사라집니다. 같은 이유로 같은 클래스 안에서 this.boardReply()처럼 부르면 프록시를 거치지 않아 트랜잭션이 걸리지 않습니다.
③ AOP — DAO를 고치지 않고 모든 DAO 메서드에 로그
로그처럼 여러 클래스에 공통으로 필요한 일(공통 관심사)을 aop/LogExecute 한 곳에 모았습니다. aop-context.xml에서 pointcutexecution(* com.hk.ansboard.dao.*Dao.*(..))("dao 패키지에서 이름이 Dao로 끝나는 클래스의 모든 메서드")을 정하고, 거기에 advice 세 개를 연결합니다 — <aop:before>(실행 전) · <aop:after-returning>(정상 종료 후) · <aop:after-throwing>(오류 시). advice 메서드는 JoinPoint로 어떤 메서드가 어떤 인자로 불렸는지 꺼내 로그를 남깁니다. web.xml이 root-context와 aop-context를 함께 읽도록 바꿔, AOP 설정이 DAO 빈과 같은 컨텍스트에 놓입니다. 같은 일을 애너테이션으로 쓴 LogExecuteNoXML은 주석 처리된 참고용이고, 트랜잭션을 XML(tx:advice)로 거는 방법도 참고용 주석으로 남아 있습니다. → 🖥️ 60. AOP로 남기는 로그
④ 생성자 주입과 얇은 Controller — 그리고 화면 마무리
Controller는 @Autowired 필드 대신 생성자 AnsController(AnsService ansService)로 서비스를 받습니다(생성자가 하나면 스프링이 알아서 그 생성자로 주입). 의존 객체가 빠지면 객체를 만드는 순간 바로 드러나고, 테스트에서 new AnsController(가짜 서비스)처럼 직접 넣기도 쉽습니다. 목록용 조립(글 목록 + 총 페이지 수 + 페이지 번호 계산)은 AnsService.getBoardListWithPaging(pnum)이 Map으로 돌려주고, Controller는 model.addAllAttributes(result) 한 줄로 담습니다 — Controller는 요청과 화면 연결만. 목록 화면은 depth만큼 들여쓰고 답글 화살표 그림(resources/img/arrow.png)을 붙였으며, 페이지 번호는 Bootstrap pagination으로 지금 쪽에 active를 줍니다. log4j.xml의 root 수준을 info → debug로 바꿨지만, 39일차에 확인했듯 이 파일은 읽히지 않습니다. → 🖥️ 61. 생성자 주입과 Service로 옮긴 조립 · 학습 여정 24단계
<!-- LogExecute 객체 등록 -->
<bean id="logAop" class="com.hk.ansboard.aop.LogExecute" />
<!-- AOP 환경설정 -->
<aop:config>
<aop:pointcut expression="execution(* com.hk.ansboard.dao.*Dao.*(..))"
id="daoLogPoint"/> <!-- ← 어디에: 모든 *Dao 메서드 -->
<!-- advice와 pointcut을 연결하는 설정 -->
<aop:aspect id="logAspect" ref="logAop">
<aop:before method="before" pointcut-ref="daoLogPoint"/> <!-- ← 실행 전 -->
<aop:after-returning method="afterReturning" pointcut-ref="daoLogPoint"/> <!-- ← 정상 종료 후 -->
<aop:after-throwing method="daoError" pointcut-ref="daoLogPoint"/> <!-- ← 오류 시 -->
</aop:aspect>
</aop:config>
// ----- LogExecute.java (Advice 구현 객체) -----
public class LogExecute {
// target 메서드가 실행되기 전에 수행될 기능을 정의
public void before(JoinPoint join) {
Logger logger=
LoggerFactory.getLogger(join.getTarget().getClass()+""); // ← 로거 이름에 "class "가 붙는다
logger.info("before실행(info):시작:{}",
join.getSignature().getName()); // ← 어떤 메서드인지
logger.debug("before실행(debug):시작:{}",join.toLongString());
}
//target 메서드가 실행된 후 반환값을 성공적으로 리턴했다면 수행될 기능을 정의
public void afterReturning(JoinPoint join) {
…
Object[] args = join.getArgs();
logger.debug("전달 파라미터:{}",Arrays.toString(args)); // ← 어떤 인자로
}
…
}
동작 설명답글 등록(boardReply.do) 한 번에 일어나는 일의 순서 먼저 예측 → 펼쳐서 확인
AnsController.boardReply → ansService.boardReply(dto) ← 실제로는 "프록시"를 호출
[트랜잭션 프록시] 트랜잭션 시작 (커넥션 하나를 이 요청에 묶음)
AnsService.boardReply 본문
"트랜젝션 활성 상태:true"
ansDao.replyUpdate → [AOP before] → UPDATE → [AOP afterReturning]
ansDao.replyInsert → [AOP before] → INSERT → [AOP afterReturning]
[트랜잭션 프록시] 정상 종료 → commit
(INSERT에서 예외가 나면 → [AOP daoError] → 예외가 밖으로 → rollback: UPDATE도 취소)
08 설정을 그대로 쓰고 DB 연결만 가짜로 바꿔 스프링을 띄워 본 정리 작업에서, 답글 요청이 commit되고, INSERT가 실패하면 rollback되며, servlet-context가 전체를 스캔하면 트랜잭션이 아예 걸리지 않는 것을 확인했습니다(아래 정리 작업 메모). 위 순서는 그 동작을 프록시·advice 위치로 풀어 쓴 것입니다.
42일차에 배운 것 — 핵심 정리
트랜잭션 — 답글 저장의 UPDATE(자리 밀기)와 INSERT(끼워 넣기)는 둘 다 성공하거나 둘 다 취소되어야 한다. @Transactional을 붙인 메서드는 정상 종료 시 commit, 실행 중 예외(RuntimeException)가 나면 rollback된다.
프록시 — @Transactional은 스프링이 AnsService를 감싼 대리 객체(프록시)를 만들어, 메서드 호출 앞뒤에 "트랜잭션 시작 → commit/rollback"을 끼워 넣는 방식으로 동작한다. 그래서 Controller가 프록시를 주입받아 호출할 때만 걸린다(servlet-context가 서비스를 또 스캔해 프록시 아닌 복제본을 주입하면 트랜잭션이 사라진다).
AOP — 로그처럼 여러 클래스에 공통으로 필요한 일을 한 곳(LogExecute)에 모아, 모든 *Dao 메서드의 실행 전·정상 종료 후·오류 시에 자동으로 끼워 넣는다. 트랜잭션도 같은 원리(프록시)로 동작한다.
생성자 주입 — 필드에 @Autowired를 붙이는 대신 생성자로 AnsService를 받으면, 필드를 final로 둘 수 있고 의존 객체가 빠지면 객체 생성 단계에서 바로 드러나며, 테스트에서 new AnsController(가짜 서비스)처럼 직접 넣기도 쉽다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
servlet-context가 다시 com.hk.ansboard 전체를 스캔하면 @Transactional은 어떻게 되나?
자식(servlet) 컨텍스트에 트랜잭션 설정이 없는 AnsService가 하나 더 만들어지고, Controller는 가까운 자식 쪽 것을 주입받습니다. 그래서 트랜잭션이 걸리지 않습니다. 같은 설정으로 재현해 보니 "트랜젝션 활성 상태:false"가 찍히고 rollback도 일어나지 않았습니다(자세히).
log4j.xml을 debug로 바꾸면 로그가 달라지나?
아닙니다. 08의 구현체는 여전히 Logback이라 log4j.xml은 읽히지 않습니다(39일차 재현). 원래부터 Logback 기본 설정으로 전체 DEBUG가 출력되고 있었습니다. 워크스페이스의 log/ 폴더도 10/2 현재 비어 있습니다.
AnsService 안에 다른 메서드를 만들고 그 안에서 boardReply(dto)를 부르면 트랜잭션이 걸릴까?
안 걸립니다(바깥 메서드에 @Transactional이 없다면). 같은 클래스 안의 호출은 this.boardReply() — 프록시가 아닌 진짜 객체 자신을 부르는 것이라, 프록시가 끼워 넣는 "트랜잭션 시작·commit/rollback"을 지나가지 않습니다. 트랜잭션은 바깥에서 프록시를 통해 들어오는 호출에만 걸린다는 것이 프록시 방식의 핵심 제약입니다.
💼 실무·코딩테스트에서는@Transactional은 스프링 면접의 최다 출제 주제 중 하나입니다. 꼭 말할 수 있어야 할 네 가지: ① 프록시로 동작한다, ② 그래서 같은 클래스 내부 호출에는 안 걸린다, ③ 기본 롤백 대상은 RuntimeException과 Error이고 체크 예외(Exception)는 rollbackFor를 지정해야 롤백된다, ④ 보통 Service 계층에 건다(여러 DAO 호출을 한 업무로 묶는 자리). AOP는 로그·트랜잭션·권한 검사처럼 "모든 곳에 필요하지만 핵심 로직은 아닌 일"을 떼어 내는 기술로 설명하면 됩니다. 실무에서는 생성자 주입이 권장 방식이고, 필드 주입보다 테스트하기 쉽다는 이유를 들 수 있으면 좋습니다.
확인함 — 08의 Java 7개 파일이 JDK 21로, -parameters 없이 컴파일된다.
확인함 — 08의 aop-context.xml 원본과 root-context.xml의 트랜잭션·스캔 설정을 그대로 쓰고 DB 연결만 가짜로 바꿔 스프링 컨테이너를 띄웠다. 답글 요청이 commit 되고, INSERT가 실패하면 rollback 되며, 스캔을 잘못 나누면 트랜잭션이 아예 없다(결과).
확인하지 않음 — 톰캣과 실제 DB로 답글·페이지 이동을 눌러 본 기록은 이번 정리에 없다.
☕ 1. 자바 기초
JDK·JRE·JVM의 관계와 컴파일 과정에서 시작해 명명법, 변수와 메모리, 기본타입과 형 변환, 연산자, 제어문, 입출력까지 — 객체지향에 들어가기 전에 반드시 몸에 붙여야 하는 문법 기본기를 정리합니다.
01
JDK · JRE · JVM과 컴파일 과정
JDKJREJVMjavac바이트코드
한 줄 요약자바 소스(.java)는 javac가 바이트코드(.class)로 컴파일하고, 그 바이트코드를 각 OS용 JVM이 기계어로 바꿔 실행하기 때문에 코드를 고치지 않고도 어떤 OS에서든 돌아간다.
쉽게 말하면자바 코드는 세계 공통어로 쓴 편지예요. javac가 한국어 편지를 "세계 공통어(바이트코드)"로 번역해 두면, 나라마다 있는 통역사(JVM)가 그 나라 말(기계어)로 읽어 줍니다. 편지를 나라마다 새로 쓸 필요가 없죠 — 이게 "한 번 작성하면 어디서나 실행(Write Once, Run Anywhere)"입니다. JDK는 편지 쓰는 도구상자 전체, JRE는 읽는 데 필요한 최소 세트, JVM은 통역사 본인입니다.
JDK ⊃ JRE ⊃ JVM 포함 관계
세 가지는 나란한 관계가 아니라 포함 관계입니다. JDK = JRE + 개발 도구이고, JRE = JVM + 라이브러리입니다. JDK(Java Development Kit)는 개발·컴파일·실행·배포에 필요한 환경 전체이고, JRE(Java Runtime Environment)는 이미 만들어진 자바 프로그램을 실행만 하는 데 필요한 라이브러리 집합, JVM(Java Virtual Machine)은 바이트코드를 실제로 해석해 돌리는 가상 기계입니다. 그래서 개발자는 JDK를 설치하고, 실행만 하는 사용자는 JRE만 있어도 됩니다.
JDK 안의 명령어들
javac는 소스(.java)를 바이트코드(.class)로 컴파일하고, java는 그 바이트코드를 해석·실행합니다. javap는 클래스 파일을 역으로 들여다보는 역어셈블러이고, javadoc은 /** */ 형식의 문서 주석을 모아 API 문서를 자동 생성합니다. 실무에서 가장 많이 쓰는 건 앞의 두 개지만, "javap로 String 클래스를 열어보면 final이 붙어 있다" 같은 확인에 javap가 쓰입니다.
컴파일 → 실행의 2단계 번역
자바는 번역이 두 번 일어납니다. ① 고급언어(사람이 읽는 자바 코드) → 바이트코드(JVM이 읽는 중간 코드, 파일 앞머리가 ca fe ba be로 시작합니다) ② 바이트코드 → 바이너리 코드(0과 1로 된 OS별 기계어). ①은 컴파일 시점에 javac가, ②는 실행 시점에 JVM이 담당합니다. 이 중간 단계 덕분에 OS 독립성이 생기지만, 대신 실행할 때 쓰는 JRE 버전이 작성할 때 쓴 JRE와 같거나 더 높아야 합니다.
환경변수 PATH와 JAVA_HOME
어느 폴더에서든 javac·java 명령을 쓰려면 JDK의 bin 폴더를 PATH에 등록해야 합니다. 관례적으로 JAVA_HOME이라는 이름으로 JDK 폴더 경로(예: C:\jdk-11)를 먼저 만들고, PATH 맨 앞에 %JAVA_HOME%\bin;을 넣습니다. 이렇게 두 단계로 나누면 나중에 JDK 버전을 바꿀 때 JAVA_HOME 한 줄만 고치면 됩니다.
JAVAHello.java + 콘솔 명령
// 패키지 선언은 항상 파일의 맨 첫 줄에 온다 (폴더 구조와 1:1로 대응)
package com.a.b;
public class Hello {
// main 메서드: JVM이 프로그램을 시작할 때 가장 먼저 찾아 실행하는 진입점
public static void main(String[] args) {
String str = "Hello!! JAVA";
System.out.println(str);
}
}
/* --- 콘솔에서 직접 컴파일·실행하기 ---
javac -d . Hello.java // -d . : 현재 폴더에 package 경로대로 폴더를 만들며 컴파일
// → com/a/b/Hello.class 생성
java com.a.b.Hello // 실행할 때는 "패키지명.클래스명" 전체 이름을 쓴다
// (Hello.class 라고 쓰면 안 된다)
*/
핵심 정리
JDK ⊃ JRE ⊃ JVM — 개발자는 JDK, 실행만 하는 사용자는 JRE면 충분하다.
바이트코드라는 중간 단계가 있어 OS에 독립적이지만, 실행 JRE 버전은 작성 JRE 버전 이상이어야 한다.
실행할 때는 파일 이름이 아니라 패키지명.클래스명 전체 이름을 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
자바가 OS를 안 가리는 진짜 이유를 '2단계 번역'으로 설명해보세요.
소스를 바로 기계어로 번역하지 않고 중간 언어인 바이트코드로 한 번만 번역해 두기 때문입니다. 기계어로 바꾸는 두 번째 단계는 OS마다 다른 JVM이 맡습니다. 그래서 개발자는 한 벌만 만들고, OS 대응은 JVM 제작자가 이미 해 둔 것을 쓰는 셈입니다.
JDK만 설치하면 되는데 JRE가 따로 존재하는 이유는?
실행만 하는 사람에게는 컴파일러가 필요 없기 때문입니다. 사용자 PC에 javac·javadoc까지 깔 이유가 없죠. 용량과 보안 면에서 손해라 실행 전용 최소 세트를 따로 둔 것입니다. 다만 요즘은 JDK에 통합되어 별도 JRE 배포는 줄었습니다.
💼 실무·코딩테스트에서는실무에서 "내 컴퓨터에선 되는데 서버에선 안 돼요"의 상당수가 JDK 버전 차이입니다. 빌드한 자바 버전보다 실행 JVM이 낮으면 UnsupportedClassVersionError가 납니다. 그래서 팀은 JDK 버전을 문서로 고정하고, Docker 이미지나 .sdkmanrc 같은 것으로 강제합니다.
TIP이클립스나 VS Code를 쓰면 저장할 때마다 자동으로 컴파일되기 때문에 javac를 직접 칠 일이 거의 없습니다. 하지만 "왜 .class 파일이 bin 폴더에 생기지?"를 이해하려면 이 명령어 흐름을 한 번은 손으로 쳐보는 게 좋습니다.
02
명명법 · 식별자 규칙 · 주석
PascalCamel상수예약어주석
한 줄 요약클래스·생성자·인터페이스는 파스칼(HelloJava), 변수·메서드는 카멜(printKey), 상수는 전부 대문자(MAX_VALUE), 패키지는 전부 소문자로 쓰며, 식별자에는 공백·특수문자(_ $ 제외)·숫자 시작·예약어를 쓸 수 없다.
쉽게 말하면이름 짓는 규칙은 옷차림 규정이에요. 클래스는 정장(첫 글자 대문자 Pascal), 변수·메서드는 캐주얼(첫 글자 소문자 Camel), 상수는 대문자로 소리치듯(MAX_SIZE), 패키지는 소문자로 조용히. 컴파일러는 규정을 어겨도 대부분 통과시켜 주지만, 사람이 읽을 때 바로 "이게 클래스구나/변수구나"를 알아채게 해 주는 약속입니다.
4가지 명명법
Pascal(첫 글자 대문자, 단어마다 대문자) — 클래스명·인터페이스·생성자. 예: JavaProgramming. Camel(첫 글자는 소문자, 이후 단어마다 대문자) — 변수·메서드명. 예: printKey, isPrime. 대문자 + 밑줄(UPPER_SNAKE_CASE: 전부 대문자, 단어는 _로 연결) — 상수. 예: PI, MAX_VALUE, NUMBER. Lower(전부 소문자) — 패키지명과 예약어. 예: com.hankyung.sales, public static void.
식별자(이름)에 쓸 수 없는 것
① 공백 금지 — han kyung(X), hankyung(O). ② 특수문자 금지, 단 _와 $는 예외 — han_kyung(O). ③ 숫자로 시작 금지, 두 번째 자리부터는 가능 — 4hk(X), h4k(O). ④ 예약어 금지 — true는 예약어라 못 쓰지만 대소문자가 다른 True는 쓸 수 있습니다(자바는 대소문자를 구분하기 때문). 예약어에는 public, void, return, new, class 등이 있습니다.
주석 3종류
//는 한 줄 주석, /* */는 여러 줄 주석, /** */는 문서 주석(API 주석)입니다. 앞의 두 개는 사람이 읽는 메모로 끝나지만, 문서 주석은 javadoc 도구가 읽어서 API 문서 HTML을 자동 생성하는 데 쓰인다는 점이 다릅니다. 이클립스에서는 Ctrl+Shift+C(한 줄 주석 토글), /** 입력 후 Enter(문서 주석 뼈대 자동 생성) 단축키를 자주 씁니다.
JAVAHelloJava.java (day01 실습)
package hk.edu20260803.day01;
// 클래스명: 파스칼 방식 (첫 글자 대문자, 의미 단위마다 대문자)
public class HelloJava {
// 상수 선언: static final + 대문자 + 밑줄 (UPPER_SNAKE_CASE)
public static final int NUMBER = 10000;
// 인스턴스 멤버필드: 카멜 방식
public int number = 10;
// 메서드명도 카멜 방식
public static void main(String[] args) {
System.out.println("Hello Java");
testMethod();
}
public static void testMethod() {
boolean isS = true; // 변수명: 카멜
int i = 100;
i = 200; // 일반 변수는 값을 다시 넣을 수 있다
final int TEST = 10; // final: 상수가 되어 변경 불가
System.out.println("메서드 실행결과: " + i);
}
}
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
명명법을 지키지 않아도 컴파일은 되는데 왜 규칙이라고 부르나?
컴파일러가 아니라 사람이 읽기 위한 약속이기 때문입니다. Student를 보면 클래스, getName을 보면 메서드, MAX_SIZE를 보면 상수임을 이름만 보고 즉시 판단할 수 있습니다. 이 판단이 안 되면 코드를 읽을 때마다 정의를 찾아가야 합니다.
주석 세 가지 중 javadoc이 읽는 것은 무엇이고 왜 따로 있나?
/** */ 문서 주석입니다. API 문서를 자동 생성하기 위해 일반 주석과 구분해 둔 것입니다. "이 메서드는 무엇을 받아 무엇을 돌려주는가"는 코드 밖으로 꺼내 공개해야 할 정보라, 내부 설명용 //와 성격이 다릅니다.
💼 실무·코딩테스트에서는실무에서 명명 규칙은 코드 리뷰의 첫 관문이고, 대부분의 팀이 Checkstyle 같은 정적 분석 도구로 자동 검사합니다(SpotBugs는 명명이 아니라 버그 패턴을 찾는 도구라 역할이 다릅니다). 규칙을 어기면 빌드가 실패하도록 걸어 두기도 합니다. "이름만 잘 지어도 주석이 절반 줄어든다"는 말이 실제로 통용됩니다.
TIP규칙을 어겨도 컴파일은 되지만(예: 클래스명을 소문자로) 팀 작업에서는 바로 지적받는 부분입니다. 특히 상수는 대문자가 눈에 띄기 때문에, "이 값은 바뀌지 않는다"는 사실을 코드만 봐도 알 수 있게 해 줍니다.
03
변수와 블록 스코프
변수대입블록변수스택
한 줄 요약변수는 값 하나를 담는 임시 기억장소로 오른쪽 값을 왼쪽에 대입하며, 중괄호 블록 안에서 선언한 변수는 그 블록을 벗어나는 순간 스택에서 사라진다.
쉽게 말하면변수는 이름표가 붙은 사물함 한 칸이에요. 한 칸에는 물건 하나만 넣을 수 있어서 새 물건을 넣으면 이전 물건은 사라집니다(마지막에 넣은 값만 남음). 그리고 사물함은 방(블록) 안에만 있어서, 방을 나가면 그 방의 사물함은 통째로 치워집니다 — 그래서 for문 안에서 만든 j는 for문 밖에서 못 씁니다.
변수의 기본 성질
변수는 임시 기억장소이고 단 하나의 값만 저장합니다. 같은 변수에 값을 여러 번 저장하면 마지막에 저장한 값만 남습니다. 대입 연산자 =는 "같다"가 아니라 "오른쪽에 있는 것을 왼쪽에 넣는다"는 뜻입니다. 그래서 i = i + 1이 수학적으로는 말이 안 되지만 프로그래밍에서는 "i에 1을 더한 값을 다시 i에 넣어라"로 자연스럽게 읽힙니다.
블록 스코프: 위에서 아래로는 되고, 아래에서 위로는 안 된다
상위 블록에서 정의한 변수는 하위 블록에서 사용할 수 있지만, 하위 블록에서 정의한 변수는 상위 블록에서 사용할 수 없습니다. 블록({ }) 안에서 선언한 변수는 그 블록 안에서만 유효하며, 블록을 벗어나는 순간 스택 메모리에서 제거되기 때문입니다.
스코프가 만드는 실전 습관
메서드가 결과를 돌려줘야 한다면, 결과를 담을 변수는 반복문 밖(메서드 블록)에 선언해야 합니다. 아래 소수 판별 예제에서 isP를 for문 밖에 둔 이유가 이것입니다 — for문 안에 선언했다면 for가 끝나는 순간 사라져서 return isP;를 쓸 수 없습니다.
JAVAIsPrimeTest.java
public class IsPrimeTest {
public boolean isPrimeTest(int num) {
boolean isP = true; // ← isP 변수 시작 (메서드 블록 전체에서 유효)
for (int j = 2; j < num; j++) { // ← j 변수 시작 (for 블록 안에서만 유효)
if (num % j == 0) { // 나누어 떨어지면 소수가 아니다
isP = false;
break; // 더 볼 필요 없으니 반복 탈출
}
} // ← 여기서 j는 스택에서 사라진다
return isP; // isP는 아직 살아 있으므로 반환 가능
} // ← 여기서 isP도 사라진다
}
핵심 정리
변수는 값 하나만 담고, 여러 번 대입하면 마지막 값만 남는다.
=는 "같다"가 아니라 "오른쪽 값을 왼쪽에 넣는다"는 대입 연산자다.
상위 블록의 변수 → 하위 블록에서 사용 가능 / 하위 블록의 변수 → 상위 블록에서 사용 불가.
반환하거나 반복문 밖에서 써야 할 값은 반복문 바깥에 선언한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
블록을 벗어난 변수를 못 쓰는 이유를 메모리로 설명해보세요.
지역 변수는 스택(Stack)에 쌓였다가 블록이 끝나는 순간 제거되기 때문입니다. 접근을 금지하는 게 아니라 애초에 존재하지 않게 되는 것이죠. 덕분에 메모리가 자동으로 정리되고, 같은 이름을 다른 블록에서 재사용할 수 있습니다.
상위 블록 변수는 하위에서 쓸 수 있는데 반대는 안 되는 이유는?
수명 때문입니다. 상위 블록이 살아 있는 동안 하위 블록이 실행되므로 상위 변수는 아직 스택에 있습니다. 반대로 하위 블록이 끝나면 그 변수는 이미 사라진 뒤라 상위에서 참조할 대상이 없습니다.
💼 실무·코딩테스트에서는코딩테스트에서 for문 안에서 선언한 변수를 밖에서 쓰려다 컴파일 에러가 나는 일이 흔합니다. 결과를 밖으로 가져와야 하면 반복문 밖에서 먼저 선언해야 합니다. 실무에서는 반대로 변수의 범위를 최대한 좁게 잡는 것이 원칙입니다 — 범위가 넓을수록 어디서 바뀌었는지 추적하기 어려워지니까요.
TIP"변수를 찾을 수 없습니다(cannot find symbol)" 컴파일 에러의 절반 이상은 스코프 문제입니다. 에러가 나면 그 변수를 어느 중괄호 안에서 선언했는지부터 확인하세요.
04
기본타입 8가지와 메모리
primitiveintdoublecharboolean스택
한 줄 요약기본타입은 정수형(byte·short·int·long)·실수형(float·double)·문자형(char)·논리형(boolean) 8가지이며 변수에 값 자체가 저장되고, 대입하거나 메서드에 넘기면 값이 복사된다.
쉽게 말하면기본타입은 크기가 정해진 택배 상자예요. byte는 손바닥만 한 상자(1바이트), int는 중간 상자(4바이트), long은 대형 상자(8바이트). 큰 상자에 작은 물건을 넣으면 남는 공간이 낭비되고, 작은 상자에 큰 물건은 아예 안 들어갑니다. 그래서 "이 값이 얼마나 커질 수 있는가"를 보고 상자 크기를 고릅니다.
8가지 기본타입과 크기
정수형: byte(1byte) · short(2byte) · int(4byte) · long(8byte). 실수형: float(4byte) · double(8byte). 문자형: char(2byte, 유니코드 문자 1개). 논리형: boolean(true/false, 보통 1byte로 설명하지만 JVM 명세는 크기를 정하지 않아 구현에 따라 다릅니다). 정수형의 표현 범위는 부호 비트 1개를 빼고 계산해서 -2ⁿ⁻¹ ~ 2ⁿ⁻¹-1이 됩니다 — byte는 -128 ~ 127, int는 약 ±21억입니다.
초기값 (멤버필드로 선언했을 때)
정수형은 0(long은 0L), 실수형은 0.0(float은 0.0F), char는 '\u0000', boolean은 false, 참조형은 null로 자동 초기화됩니다. 단 지역변수는 자동 초기화되지 않으므로 반드시 직접 값을 넣어야 컴파일됩니다.
값 자체가 저장되고, 넘기면 복사된다
기본타입 변수에는 주소가 아니라 값 그 자체가 들어 있습니다. 저장 위치는 어디에 선언했느냐로 갈립니다 — 메서드 안의 지역변수·매개변수는 스택(Stack)에, 객체의 인스턴스 필드는 그 객체와 함께 힙(Heap)에, static 필드는 클래스 정보와 함께 Method Area에 있습니다. 스택은 LIFO(Last In First Out) 구조라 블록·메서드가 끝나면 그 안의 지역변수가 즉시 정리됩니다. 또 기본타입을 다른 변수에 대입하거나 메서드에 넘기면 값이 복사되므로, 받은 쪽에서 값을 바꿔도 원본은 그대로입니다(pass by value).
리터럴의 기본 타입
코드에 그냥 10이라고 쓰면 그 리터럴은 int로, 3.14라고 쓰면 double로 해석됩니다. 그래서 long a = 10000000000;은 int 범위를 넘어 컴파일 에러가 나고 10000000000L처럼 L을 붙여야 하며, float f = 3.14;도 double을 float에 넣는 것이라 에러가 나고 3.14F로 써야 합니다.
JAVAPrimitiveType.java
public class PrimitiveType {
// 멤버필드는 자동 초기화된다
static int i; // 0
static double d; // 0.0
static boolean b; // false
static String s; // null (참조형)
public static void main(String[] args) {
byte by = 100; // -128 ~ 127
short sh = 30000; // 약 ±3만
int in = 2100000000; // 약 ±21억 — 가장 많이 쓰는 정수 타입
long lo = 10000000000L; // int 범위를 넘으면 반드시 L을 붙인다
float fl = 3.14F; // 실수 리터럴의 기본은 double이라 F 필수
double db = 3.141592; // 실수는 특별한 이유가 없으면 double
char ch = 'A'; // 작은따옴표 1글자 (2byte 유니코드)
boolean isOk = true; // true / false 만 가능 (자바는 0/1을 못 쓴다)
System.out.println(by + " " + sh + " " + in + " " + lo);
System.out.println(fl + " " + db + " " + ch + " " + isOk);
System.out.println(i + " " + d + " " + b + " " + s); // 0 0.0 false null
}
}
핵심 정리
정수 byte(1)·short(2)·int(4)·long(8) / 실수 float(4)·double(8) / char(2) / boolean(보통 1, 구현에 따라 다름).
멤버필드는 자동 초기화되지만 지역변수는 직접 초기화해야 한다.
정수 리터럴의 기본은 int, 실수 리터럴의 기본은 double — 벗어나면 L·F를 붙인다.
기본타입 변수에는 값이 그대로 저장되고(지역변수는 스택, 필드는 객체와 함께 힙/static은 Method Area), 대입·전달하면 값이 복사된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
int가 8바이트가 아니라 4바이트인 것이 실제로 문제가 되는 순간은?
약 21억을 넘는 순간입니다. 큰 수를 곱하거나 누적할 때 조용히 오버플로가 나서 음수가 되어 버립니다. 에러가 아니라 틀린 값이 나오는 게 무서운 점이죠. 그래서 합계·곱셈이 커질 것 같으면 long으로 받습니다.
boolean에 실제로 필요한 건 1비트인데 보통 1바이트로 설명한다. 왜 그럴까?
메모리는 바이트 단위로 주소가 매겨지기 때문입니다. 1비트짜리 공간에는 주소를 붙일 수 없어서 흔히 최소 단위인 1바이트를 씁니다(단 JVM 명세는 boolean 크기를 정하지 않아 실제 크기는 구현에 따라 다릅니다). 이런 이론적 최소와 실제 구현의 차이를 알아 두면 자료구조를 볼 때 도움이 됩니다.
💼 실무·코딩테스트에서는코딩테스트에서 가장 흔한 오답 원인 중 하나가 int 오버플로입니다. 문제에 "n은 100억 이하" 같은 조건이 있으면 그 자체가 long을 쓰라는 신호입니다. 누적 합계는 입력이 int라도 결과가 커질 수 있으니 long sum으로 받는 습관이 안전합니다.
TIP자바의 boolean은 C 언어와 달리 0/1을 쓸 수 없습니다. if (1)은 컴파일 에러이고 반드시 if (isOk)처럼 boolean 결과가 와야 합니다 — 이 덕분에 if (a = b) 같은 대입 실수가 컴파일 단계에서 걸러집니다.
05
진법과 음수 표현(2의 보수)
2진수8진수16진수2의 보수부호비트
한 줄 요약컴퓨터는 맨 앞 비트를 부호로 쓰고 음수는 "1의 보수 + 1"인 2의 보수로 저장하기 때문에, 1바이트의 범위가 -128 ~ 127이라는 비대칭이 생긴다.
쉽게 말하면2의 보수는 "더해서 0이 되는 짝"을 음수로 정한 방식이에요. 127을 나타내는 비트에 어떤 비트를 더했을 때 (자리 넘침을 무시하고) 0이 되면, 그 비트가 -127입니다. 부호 비트만 1로 바꾸는 단순한 방법은 통하지 않아요. 127(01111111)의 부호 비트만 바꾼 11111111은 2의 보수 규칙에서 -127이 아니라 -1로 읽히기 때문입니다. 진짜 -127은 10000001이고, 그래서 아래의 "뒤집고 1 더하기" 방식을 씁니다.
진법의 정의와 비트 수
2진수는 0·1, 8진수는 0~7, 10진수는 0~9, 16진수는 0~9와 A~F로 표현합니다. 1비트는 2가지(0·1)를 표현하므로, 8진수 한 자리는 2진수 3비트(2³=8), 16진수 한 자리는 2진수 4비트(2⁴=16)가 필요합니다. 그래서 2진수 ↔ 8진수 변환은 오른쪽부터 3자리씩, 16진수 변환은 4자리씩 끊어서 계산합니다.
10진수 ↔ 2진수 변환
10진수 → 2진수는 2로 계속 나누고 나머지를 거꾸로 읽습니다. 10 ÷ 2 = 5…0, 5 ÷ 2 = 2…1, 2 ÷ 2 = 1…0, 마지막 몫 1 → 아래에서 위로 읽으면 1010. 반대로 2진수 → 10진수는 자리값(8·4·2·1)을 곱해 더합니다 — 1010은 8+0+2+0 = 10.
부호 비트와 2의 보수
맨 앞 비트가 부호 비트로, 0이면 양수 1이면 음수입니다. 음수는 ① 모든 비트를 뒤집고(1의 보수) ② 1을 더해서(2의 보수) 만듭니다. 127(01111111)의 1의 보수는 10000000, 여기에 1을 더하면 10000001이고 이것이 -127입니다. 실제로 01111111 + 10000001은 자리 넘침을 버리면 0이 됩니다. 이 방식 때문에 음수 쪽이 하나 더 많아 -128 ~ 127이라는 비대칭 범위가 나옵니다(맨 앞 비트의 자리값을 -128로 보면 계산이 맞아떨어집니다).
왜 타입 크기를 신경 쓰는가
3이라는 값을 int(32비트)에 담으면 실제로 쓰이는 건 뒤쪽 2비트뿐이고 나머지 30비트는 낭비됩니다. 다만 byte·short도 연산할 때는 int로 승격되어 계산되므로(byte + byte의 결과는 int), 변수 하나에 byte를 써서 얻는 이득은 거의 없습니다. 그래서 실무의 기본은 int이고, byte·short는 수백만 개를 담는 대량 배열처럼 저장 크기가 실제로 문제 될 때 의미가 있습니다.
JAVARadixTest.java
public class RadixTest {
public static void main(String[] args) {
int n = 17;
// 10진수 → 다른 진법 문자열 (Integer 클래스의 유틸 메서드)
System.out.println(Integer.toBinaryString(n)); // "10001" (2진수)
System.out.println(Integer.toOctalString(n)); // "21" (8진수)
System.out.println(Integer.toHexString(n)); // "11" (16진수)
// 반대로 문자열(진법) → 10진수
System.out.println(Integer.parseInt("10001", 2)); // 17
System.out.println(Integer.parseInt("ff", 16)); // 255
// byte의 한계 확인 — 127에 1을 더하면 -128로 되돌아간다(오버플로)
byte max = 127;
max++;
System.out.println(max); // -128
}
}
핵심 정리
8진수 = 2진수 3비트, 16진수 = 2진수 4비트 단위로 끊어서 변환한다.
10진수 → 2진수는 2로 나눈 나머지를 거꾸로, 2진수 → 10진수는 자리값을 곱해 더한다.
음수 = 1의 보수(비트 반전) + 1 = 2의 보수. 부호 비트만 뒤집는 게 아니다.
음수가 하나 더 많아 byte는 -128 ~ 127이며, 최대값에서 1을 더하면 최소값으로 돌아간다(오버플로).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
2의 보수를 쓰면 무엇이 편해지는지 설명해보세요.
뺄셈을 덧셈 회로 하나로 처리할 수 있게 됩니다. 5 - 3을 5 + (-3)으로 계산해도 답이 맞아떨어지죠. 또 0이 하나뿐이라는 장점도 있습니다(부호 절댓값 방식에는 +0과 -0이 따로 생깁니다).
byte의 범위가 -128 ~ 127로 음수 쪽이 하나 더 많은 이유는?
2의 보수에서 0이 양수 쪽 자리를 하나 차지하기 때문입니다. 8비트로 256가지를 표현하는데 0을 양수 구간에 넣으니 양수는 0~127로 128개, 음수는 -1~-128로 128개가 됩니다.
💼 실무·코딩테스트에서는실무에서 직접 2진수를 다룰 일은 드물지만, 비트 연산(권한 플래그, 상태 비트마스크)이나 해시·암호 코드를 읽을 때 필요합니다. 코딩테스트에서는 비트마스킹으로 부분집합을 표현하는 문제가 종종 나오는데, 그 기초가 여기입니다.
TIP실무에서 2의 보수를 직접 계산할 일은 드물지만, "왜 int 최대값에 1을 더했더니 음수가 나오지?"라는 버그를 만났을 때 원인을 아는 사람과 모르는 사람의 차이가 큽니다. 큰 수를 다룰 땐 long이나 BigInteger를 고려하세요.
06
형 변환(캐스팅)과 타입 변환 총정리
자동(확대) 형변환강제(축소) 형변환parseIntvalueOfBoxing
한 줄 요약작은 타입 → 큰 타입은 자동(확대) 형변환, 큰 타입 → 작은 타입은 (byte)처럼 직접 명시하는 강제(축소) 형변환이며, 연산 시에는 두 피연산자가 더 큰 타입(최소 int)으로 맞춰진 뒤 계산된다.
쉽게 말하면자동(확대) 형변환은 작은 컵의 물을 큰 컵에 붓는 것이라 아무 문제 없이 자동으로 됩니다. 강제(축소) 형변환은 큰 컵의 물을 작은 컵에 붓는 것이라 넘칠 수 있어서, "넘쳐도 내가 책임진다"고 (byte)처럼 서명해야 합니다. (수업에서는 이것도 업캐스팅/다운캐스팅이라고 불렀지만, 그 이름은 보통 부모↔자식 참조타입 변환을 가리키므로 기본타입에서는 확대/축소라고 구분해 두면 덜 헷갈립니다 → 🧱 20. 참조타입 형 변환)
자동(확대) 형변환 vs 강제(축소) 형변환
byte a = 10; int c = a;처럼 작은 타입에서 큰 타입으로 가는 건 값이 손상될 일이 없어 자동(확대) 형변환입니다. 반대로 int d = 20; byte e = d;는 컴파일 에러이고, byte e = (byte)d;처럼 강제(축소) 형변환을 해야 합니다. 이때 범위를 넘으면 앞쪽 비트가 잘려나가 엉뚱한 값이 되므로 주의해야 합니다.
연산 시 자동 승격 규칙
① 정수 연산에서 int보다 작은 타입은 모두 int로 변환되어 계산됩니다. 그래서 byte + byte의 결과는 int입니다 — byte c = a + b;(변수끼리)는 에러지만 byte b = 5 + 10;(상수끼리)은 컴파일 시점에 15로 계산되므로 가능합니다. ② 그 밖에는 두 피연산자 중 더 큰 타입으로 맞춰집니다 — int + long = long, float * float = float(double로 바뀌지 않음), float + double = double. ③ 정수와 실수를 섞으면 실수형으로 변환됩니다 — 크기와 무관하게 실수가 더 넓은 범위를 표현하기 때문에 float + long = float이 됩니다.
char ↔ int, 그리고 문자 숫자 → 진짜 숫자
char는 내부적으로 유니코드 번호(숫자)라서 int와 자유롭게 오갑니다. (char)65는 'A', (int)'A'는 65, (char)('A' + 2)는 'C'입니다. 문자 '9'를 숫자 9로 바꾸려면 '9' - '0'(문자 코드 차이)이나 Character.getNumericValue('9')를 씁니다.
문자열 ↔ 숫자, 그리고 Boxing/UnBoxing
문자열 → 숫자는 Integer.parseInt("9"), Double.parseDouble("34.5"). 숫자 → 문자열은 String.valueOf(9)나 간단히 9 + "". 기본타입을 Wrapper 클래스에 넣는 것이 Boxing(Integer ik = 4;), 반대로 꺼내는 것이 UnBoxing(int a = ik;)이며 Java 5부터 자동으로 처리됩니다(오토박싱). Wrapper를 Object에 담고 꺼내는 예와 캐싱 함정은 🧱 10. final · 상수와 Wrapper 클래스에서 자세히 다룹니다.
JAVACastingTest.java
public class CastingTest {
public static void main(String[] args) {
// ---- 자동(확대) 형변환 / 강제(축소) 형변환 ----
byte a = 10;
int c = a; // 자동(확대) 형변환: byte → int
int d = 300;
byte e = (byte)d; // 강제(축소) 형변환: 범위를 넘어 44가 된다(비트가 잘림)
System.out.println(c + " " + e);
// ---- 연산 시 자동 승격 ----
byte b1 = 5, b2 = 10;
// byte sum = b1 + b2; // 에러! byte + byte 의 결과는 int
byte sumOk = 5 + 10; // OK: 상수끼리는 컴파일 시점에 15로 확정
int sum = b1 + b2; // 결과를 int로 받으면 문제 없다
System.out.println(sumOk + " " + sum);
// ---- char ↔ int ----
System.out.println((char)65); // A
System.out.println((int)'A'); // 65
System.out.println((char)('A' + 2)); // C
System.out.println('9' - '0'); // 9 (문자 숫자 → 진짜 숫자)
// ---- String ↔ 숫자 ----
int n = Integer.parseInt("9"); // "9" → 9
double dd = Double.parseDouble("34.5"); // "34.5" → 34.5
String s1 = String.valueOf(9); // 9 → "9"
String s2 = 9 + ""; // 같은 결과, 더 짧은 방법
System.out.println(n + " " + dd + " " + s1 + s2);
// ---- Boxing / UnBoxing (Java 5 이상 자동) ----
Integer box = 4; // Boxing : int → Integer
int un = box; // UnBoxing : Integer → int
System.out.println(box + " " + un); // Object에 담고 꺼내는 예는 oop-10 카드에서
}
}
핵심 정리
작은 → 큰 = 자동(확대) 형변환, 큰 → 작은 = 강제(축소) 형변환(명시적 (타입) 필요, 값 손실 가능).
연산은 두 피연산자를 더 큰 타입(최소 int)으로 맞춘 뒤 계산한다 — float * float는 float 그대로.
int보다 작은 정수 타입끼리 연산하면 결과는 int — byte + byte를 byte에 담으면 에러.
정수 + 실수 = 실수. 크기와 무관하게 실수형이 더 넓은 범위를 표현한다.
문자열 → 숫자는 parseInt/parseDouble, 숫자 → 문자열은 valueOf 또는 + "".
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
자동 형 변환은 되는데 강제 형 변환에 (int)를 써야 하는 이유는?
값이 손실될 수 있기 때문입니다. 작은 그릇에서 큰 그릇으로 옮기는 건 안전하지만(자동), 큰 그릇에서 작은 그릇으로 옮기면 넘칠 수 있죠. 컴파일러가 막아 두고 "손실을 감수하겠다"는 표시를 강제하는 것이 (int)입니다.
(int)3.9의 결과가 4가 아니라 3인 이유는?
형 변환은 반올림이 아니라 버림이기 때문입니다. 소수부를 그냥 잘라냅니다. 반올림하려면 Math.round()를 써야 합니다. "형 변환 = 자르기"로 기억하면 헷갈리지 않습니다.
💼 실무·코딩테스트에서는코딩테스트에서 정수 나눗셈과 형 변환이 섞이면 자주 틀립니다. (double)(a/b)는 이미 정수 나눗셈을 한 뒤 변환하므로 소수점이 날아갑니다 — (double)a / b로 나누기 전에 변환해야 합니다. 평균 구하는 문제에서 단골로 나오는 함정입니다.
TIPScanner로 받은 값이나 화면에서 읽은 값은 대부분 문자열입니다. "3" + "4"는 34이지만 Integer.parseInt("3") + Integer.parseInt("4")는 7이라는 차이를 항상 의식하세요.
07
연산자 총정리 (관계 · 단축 · 증감 · 논리 · 삼항)
++ii++단축연산자short circuit삼항연산자
한 줄 요약관계연산자는 true/false를 만들고, 단축연산자(+=)는 코드를 줄이며, 증감연산자는 ++가 앞에 붙는지 뒤에 붙는지로 처리 순서가 달라지고, &&·||는 앞 조건만으로 결과가 정해지면 뒤 조건을 아예 실행하지 않는다(short circuit).
쉽게 말하면i++는 "먼저 보여주고 나중에 올리기", ++i는 "먼저 올리고 보여주기"예요. 그리고 &&는 까다로운 면접관 같아서 첫 질문에서 탈락(false)이면 두 번째 질문을 아예 안 합니다. ||는 반대로 첫 질문에서 합격(true)이면 더 안 물어봅니다.
관계연산자 · 단축연산자
관계연산자(>>=<<===!=)는 두 값의 관계를 판단해 true/false를 반환합니다. 단축(복합대입)연산자는 x += t가 x = x + t와 같은 식으로 반복을 줄여 줍니다. -=, *=, /=, %=가 같은 방식이고, &=·|=는 2진수로 바꿔 비트 단위 AND/OR을 수행합니다.
증감연산자: 전위 vs 후위
i++(후위)는 현재 값을 먼저 사용한 뒤 1 증가시키고, ++i(전위)는 1 먼저 증가시킨 뒤 그 값을 사용합니다. x = 10일 때 println(x++)은 10을 출력하고 x는 11이 되며, 이어서 println(++x)는 12를 출력합니다. 감소(--)도 똑같은 규칙입니다.
논리연산자와 short circuit
!(부정) &&(그리고) ||(또는)가 있습니다. &·|는 앞뒤 조건을 무조건 둘 다 실행하지만, &&·||는 앞 조건의 결과에 따라 뒤 조건의 실행 여부를 결정합니다. AND에서 앞이 false면 뒤를 봐도 결과는 무조건 false이므로 실행하지 않고, OR에서 앞이 true면 마찬가지로 뒤를 실행하지 않습니다. 이것이 쇼트 서킷(short circuit)이며, 불필요한 실행을 막아 속도를 높입니다.
삼항연산자와 우선순위
조건 ? 참일 때 : 거짓일 때 형태로, 반드시 값을 반환하므로 대입문의 오른쪽에 씁니다. x = (y < 0) ? 10 : 20;은 y가 음수면 10, 아니면 20을 x에 넣습니다. 연산자 우선순위는 단항 → 산술 → 비교 → 논리 → 삼항 → 대입 순이고, 헷갈리면 괄호로 명시하는 것이 정답입니다.
JAVAOperatorTest.java
public class OperatorTest {
public static void main(String[] args) {
// ---- 단축연산자 ----
int sum = 10;
sum += 1; // sum = sum + 1 → 11
System.out.println(sum);
// ---- 증감연산자 ----
int x = 10;
System.out.println(x++); // 10 출력 후 11이 됨 (후위: 쓰고 나서 증가)
System.out.println(x); // 11
System.out.println(++x); // 12 (전위: 증가하고 나서 씀)
System.out.println(x); // 12
// ---- 논리연산자 ----
int i = 1, j = 2, k = 3;
System.out.println(i < j && j < k); // true (둘 다 참)
System.out.println(i < j || j < k); // true (하나만 참이어도)
System.out.println(!(i < j) || !(j < k)); // false
// ---- short circuit: 앞이 false면 뒤(divide)는 아예 실행되지 않는다 ----
int zero = 0;
if (zero != 0 && 100 / zero > 1) { // 앞이 false → 나눗셈을 하지 않아 에러가 안 난다
System.out.println("실행되지 않음");
}
// ---- 삼항연산자 ----
int y = -5;
int result = (y < 0) ? 10 : 20; // y가 음수이므로 10
System.out.println(result);
}
}
핵심 정리
i++는 쓰고 나서 증가, ++i는 증가하고 나서 쓴다.
&&/||는 short circuit — 앞 조건으로 결과가 확정되면 뒤 조건을 실행하지 않는다.
&/|는 short circuit이 없어 양쪽을 항상 실행한다.
삼항연산자는 반드시 값을 반환하므로 변수 = 조건 ? A : B; 형태로 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
&&와 &의 차이를 '단축 평가'로 설명해보세요.
&&는 왼쪽이 false면 오른쪽을 아예 실행하지 않습니다(단축 평가). &는 양쪽을 모두 계산합니다. 그래서 obj != null && obj.getName().equals("hi")처럼 앞 조건으로 뒤를 보호하는 패턴은 &&여야만 동작합니다.
i++ 와 ++i 의 차이가 실제로 결과를 바꾸는 경우는?
그 값을 바로 쓸 때입니다. int b = a++;는 증가 전 값을 대입하고, int b = ++a;는 증가 후 값을 대입합니다. 단독으로 a++;만 쓰면 둘은 완전히 같습니다 — 값을 쓰지 않으니까요.
💼 실무·코딩테스트에서는a != null && a.isEmpty() 패턴은 실무에서 NullPointerException을 막는 가장 기본적인 방어입니다. 단축 평가가 없다면 이 코드가 동작하지 않겠죠. 반대로 부수 효과가 있는 함수를 &&의 오른쪽에 두면 실행이 건너뛰어져 버그가 되니 주의해야 합니다.
TIPif (obj != null && obj.getName().equals("hi"))처럼 null 체크를 앞에 두는 패턴이 short circuit의 대표적 실전 용도입니다. 순서를 바꾸면 NullPointerException이 터집니다.
08
조건문: if · switch ~ case
ifelseswitchcasebreak
한 줄 요약if는 boolean 조건식으로 흐름을 나누고, switch는 하나의 값을 여러 case와 비교해 분기하며 각 case 끝에 break가 없으면 아래 case로 계속 흘러내려간다.
쉽게 말하면if는 갈림길에서 매번 "예/아니오"를 묻는 것이고, switch는 번호표를 뽑아 창구로 한 번에 안내하는 것이에요. 조건이 2~3개면 if가 자연스럽고, 값 하나로 대여섯 갈래가 갈리면 switch가 읽기 쉽고 빠릅니다(조건식을 한 번만 계산하니까요).
if문: 괄호 안은 반드시 boolean
if (조건식) { 문장 } 형태이며 조건식의 결과는 반드시 boolean이어야 합니다(C처럼 0/1을 쓸 수 없습니다). else로 반대 경우를, else if로 여러 갈래를 이어 붙일 수 있고, 위에서부터 검사해 가장 먼저 true가 된 블록 하나만 실행합니다.
switch ~ case의 장점과 비교 대상
switch는 조건식을 단 한 번만 계산하고 그 결과로 바로 분기하기 때문에, 같은 값을 여러 번 비교하는 if-else 사슬보다 빠릅니다. 비교 대상으로 쓸 수 있는 타입은 int 이하 정수형(int·byte·short·char)과 그 Wrapper(Integer·Byte·Short·Character), enum, String(자바 1.7부터)입니다 — long·double·boolean은 쓸 수 없습니다. 참고로 Java 14부터는 case 1 -> ...처럼 화살표 문법을 쓸 수 있는데, 이 형태는 break 없이도 아래 case로 흘러내리지 않습니다.
break를 빠뜨리면 흘러내린다(fall through)
case에 걸리면 그 지점부터 아래로 계속 실행되므로, 한 case만 실행하려면 끝에 break;가 필요합니다. 반대로 이 성질을 일부러 이용해 "case 1, 2, 3을 같은 처리로 묶기"를 할 수도 있습니다. 어느 case에도 안 걸릴 때는 default가 실행됩니다.
JAVASwitchCase.java
public class SwitchCase {
public static void main(String[] args) {
int i = 95;
// i / 10 을 한 번만 계산해서 그 값으로 분기한다 (95/10 = 9)
switch (i / 10) {
case 10: System.out.println(i + "점은 A입니다."); break;
case 9: System.out.println(i + "점은 A입니다."); break; // ← 여기 실행
case 8: System.out.println(i + "점은 B입니다."); break;
case 7: System.out.println(i + "점은 C입니다."); break;
case 6: System.out.println(i + "점은 D입니다."); break;
default: System.out.println(i + "점은 F입니다."); break;
}
// fall through 를 일부러 이용해 여러 case 를 하나로 묶기
int month = 3;
switch (month) {
case 3: case 4: case 5:
System.out.println("봄"); break; // 3,4,5 모두 여기로
case 6: case 7: case 8:
System.out.println("여름"); break;
default:
System.out.println("그 외");
}
// 같은 로직을 if 로 쓰면 조건식을 매번 계산한다
if (i >= 90) System.out.println("A");
else if (i >= 80) System.out.println("B");
else System.out.println("C 이하");
}
}
핵심 정리
if의 조건식 결과는 반드시 boolean — 자바에서 if (1)은 컴파일 에러다.
switch는 조건식을 한 번만 계산해 분기하므로 갈래가 많을 때 유리하다.
switch 비교 대상: int·byte·short·char와 그 Wrapper, enum, String(1.7+). long·double·boolean은 불가.
break를 빼면 아래 case로 계속 흘러내려간다(fall through) — 실수이자 활용법.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
if-else if 사슬과 switch 중 무엇을 언제 쓰는 게 나은가?
범위 조건(점수 90 이상 등)이면 if, 값이 딱 떨어지는 분기(요일·메뉴 번호)면 switch가 읽기 좋습니다. switch는 한 변수의 값만 비교할 수 있다는 제약이 있는 대신, 분기가 많을 때 의도가 훨씬 명확히 드러납니다.
switch에서 break를 빼면 어떻게 되고, 그게 유용한 경우도 있나?
다음 case로 그대로 흘러갑니다(fall-through). 대부분은 버그지만, 여러 값을 같은 처리로 묶을 때는 일부러 씁니다 — case 1: case 2: case 3:처럼 나란히 두고 마지막에만 처리를 적는 식이죠.
💼 실무·코딩테스트에서는실무에서 if-else가 다섯 단계 넘게 중첩되면 리팩터링 신호로 봅니다. 조건을 뒤집어 일찍 return하는 방식(early return)으로 펴거나, 분기 자체를 다형성으로 대체합니다. 코딩테스트에서는 경계값 조건(이상/초과)을 정확히 옮기는 것이 관건입니다.
TIP실습과제 6번 윤년 구하기처럼 조건이 "4의 배수이고 100의 배수가 아니거나, 400의 배수"처럼 복합적일 때는 if 하나에 &&·||를 조합하는 게 switch보다 명확합니다.
09
반복문: for · while · do~while · break · continue
forwhiledo-whilecontinue라벨
한 줄 요약for는 반복 횟수가 정해졌을 때, while은 조건만으로 돌 때, do~while은 최소 한 번은 실행해야 할 때 쓰며, break는 반복 자체를 탈출하고 continue는 이번 회차만 건너뛴다.
쉽게 말하면for는 "10바퀴 뛰자"(횟수 고정), while은 "지칠 때까지 뛰자"(조건만 있음), do~while은 "일단 한 바퀴는 뛰고 나서 더 뛸지 정하자"입니다. break는 운동장을 나가는 것, continue는 이번 바퀴만 대충 넘기고 다음 바퀴로 가는 것이에요.
for문의 4요소
for (초기화문; 조건식; 증감식) { 문장 }. 실행 순서는 초기화 → 조건 검사 → 문장 → 증감 → 조건 검사 → …이며 조건이 false가 되면 빠져나옵니다. 초기화문에서 선언한 변수(int i)는 for 블록 안에서만 유효합니다(03. 블록 스코프 참고).
while vs do~while
while (조건) { }은 조건을 먼저 검사하므로 처음부터 false면 한 번도 실행되지 않습니다. do { } while (조건);은 문장을 먼저 실행하고 조건을 나중에 검사하므로 최소 한 번은 반드시 실행됩니다. "일단 메뉴를 한 번은 보여주고, 그만두겠다고 할 때까지 반복"하는 상황에 do~while이 맞습니다.
break와 continue
break는 반복문(또는 switch의 case)을 완전히 빠져나오며, 중첩 반복문에서는 가장 가까운 반복문 하나만 탈출합니다. continue는 남은 코드를 건너뛰고 다음 회차로 점프합니다 — for문에서는 증감식으로, while·do~while문에서는 조건식으로 갑니다(그래서 while에서 증감 코드보다 continue가 먼저 오면 무한 루프가 될 수 있습니다) — 특정 값만 처리에서 제외하고 싶을 때 씁니다.
라벨(label) break — 중첩 반복 한 번에 탈출
중첩된 반복문을 한꺼번에 빠져나오려면 블록에 이름:을 붙이고 break 이름;을 씁니다. 안쪽 for에서 break aa;를 만나면 aa: 블록 전체를 빠져나갑니다. 자주 쓰이진 않지만 2차원 배열 탐색(예: 마방진에서 값 찾기)에서 유용합니다.
덤: 등차수열과 등비수열
반복문 연습에 자주 나오는 두 수열입니다. 등차수열은 앞 항에 일정한 수(공차 d)를 더해 만드는 수열로 일반항은 an = a1 + (n-1)d, 등비수열은 일정한 수(공비 r)를 곱해 만드는 수열로 일반항은 an = a1 × r^(n-1)입니다. 코드로는 각각 a += d;와 a *= r;을 반복문 안에 두면 됩니다.
JAVALoopTest.java
public class LoopTest {
public static void main(String[] args) {
// ---- continue: 짝수는 건너뛰고 홀수만 출력 ----
for (int i = 0; i < 10; i++) {
if (i % 2 == 0) continue; // 짝수면 아래를 건너뛰고 i++ 로 점프
System.out.println("[" + i + "]");
}
// ---- while: 조건을 먼저 검사 ----
int r = 1;
while (r <= 3) {
System.out.println("반지름 " + r + " 원의 넓이: " + (Math.PI * r * r));
r++; // 증감을 깜빡하면 무한 루프!
}
// ---- do~while: 조건이 처음부터 false여도 한 번은 실행 ----
int i = 15;
do {
i++;
System.out.println("i 값? " + i); // 16 한 번만 출력됨
} while (i > 20);
// ---- 라벨 break: 중첩 반복을 한 번에 탈출 ----
outer:
for (int a = 0; a < 3; a++) {
for (int b = 0; b < 3; b++) {
if (a * b > 2) break outer; // 안쪽에서 바깥 for까지 통째로 탈출
System.out.println(a + "," + b);
}
}
// ---- 등차수열(+3) / 등비수열(×2) ----
int ap = 1, gp = 1;
for (int n = 0; n < 5; n++) {
System.out.print(ap + " "); ap += 3; // 1 4 7 10 13
}
System.out.println();
for (int n = 0; n < 5; n++) {
System.out.print(gp + " "); gp *= 2; // 1 2 4 8 16
}
}
}
핵심 정리
for = 횟수가 정해진 반복 / while = 조건 기반 반복 / do~while = 최소 1회 보장.
break는 가장 가까운 반복문 하나만 탈출, continue는 이번 회차만 건너뛴다.
중첩 반복을 한 번에 나오려면 라벨: + break 라벨;을 쓴다.
while문에서 증감식을 빠뜨리면 무한 루프가 되므로 항상 확인한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
for와 while 중 어느 쪽을 고를지 판단하는 기준은?
반복 횟수를 미리 아느냐입니다. "10번 돌린다", "배열 끝까지"처럼 횟수가 정해져 있으면 for가 자연스럽고, "사용자가 그만둘 때까지"처럼 조건에 달려 있으면while이 맞습니다. do~while은 최소 한 번은 반드시 실행해야 할 때입니다.
break와 continue의 차이를 한 문장으로 말해보세요.
break는 반복문을 완전히 빠져나가고, continue는 이번 회차만 건너뛰고 다음 회차로 갑니다. 중첩 반복문에서 break는 가장 안쪽 하나만 빠져나간다는 점이 함정입니다 — 전부 빠져나오려면 라벨이나 플래그가 필요합니다.
💼 실무·코딩테스트에서는코딩테스트에서 이중 반복문의 break가 안쪽만 빠져나온다는 걸 놓쳐 틀리는 경우가 많습니다. 바깥까지 나가려면 outer: 라벨을 쓰거나, 메서드로 분리해 return하는 편이 깔끔합니다. 실무에서는 라벨보다 메서드 분리를 선호합니다.
TIP실습과제 대부분(약수·최대공약수·완전수·개미수열)이 "반복문 안에서 % 연산으로 조건을 확인한다"는 같은 뼈대를 씁니다. 🧪 실습과제 탭에서 그 패턴을 확인해 보세요.
10
출력(print · println · printf)과 escape 문자
System.outprintf%d %s\n\t
한 줄 요약print는 줄바꿈 없이, println은 출력 후 줄바꿈, printf는 %d·%s·%f 같은 서식 문자로 값을 끼워 넣어 출력하며, 문자열 안의 \n·\t·\\ 같은 escape 문자는 특수한 의미를 갖는다.
쉽게 말하면print는 같은 줄에 계속 적기, println은 적고 나서 엔터, printf는 빈칸 뚫린 양식지에 값을 채워 넣기예요. "나이:__ 이름:__"이라는 양식을 만들고 %d·%s 자리에 값을 순서대로 꽂는 겁니다.
print vs println vs printf
System.out.print()는 출력 후 줄을 바꾸지 않아 여러 값을 한 줄에 이어 붙일 때 씁니다. System.out.println()은 출력 후 자동으로 줄바꿈(\n)합니다. System.out.printf("서식", 값...)은 서식 문자열의 % 자리에 값을 순서대로 채웁니다 — %s(String), %d(정수, digit), %f(실수). %.2f처럼 소수점 자리수를 지정할 수도 있어 표 형태 출력에 유용합니다.
문자열 연결(+)의 함정
+는 피연산자 중 하나라도 문자열이면 연결(concatenation)로 동작합니다. 왼쪽부터 순서대로 계산하므로 1 + 2 + "a"는 "3a"이지만 "a" + 1 + 2는 "a12"가 됩니다. 숫자 계산을 먼저 하고 싶다면 "합: " + (a + b)처럼 괄호로 묶어야 합니다.
escape 문자
문자열 안에서 백슬래시(\)로 시작하는 특수 표기입니다. \n(줄바꿈), \t(탭 이동), \\(백슬래시 자체), \"(큰따옴표), \'(작은따옴표), \r(캐리지 리턴), \b(백스페이스)가 대표적입니다. 윈도우 파일 경로를 문자열로 쓸 때 "C:\\java\\bin"처럼 백슬래시를 두 번 써야 하는 이유가 이것입니다.
JAVAPrintTest.java
public class PrintTest {
public static void main(String[] args) {
// print: 줄바꿈 없음 → 한 줄에 이어진다
System.out.print("Hello ");
System.out.print("Java");
System.out.println(); // 줄바꿈만 하고 싶을 때
// println: 출력 후 자동 줄바꿈
System.out.println("한 줄 출력");
// printf: 서식 문자로 값 끼워 넣기
System.out.printf("나이:%d 이름:%s%n", 30, "한경");
System.out.printf("원주율: %.2f%n", Math.PI); // 소수점 2자리 → 3.14
// + 연산의 순서 함정
int a = 1, b = 2;
System.out.println(a + b + "점"); // "3점" (숫자 계산이 먼저)
System.out.println("점수: " + a + b); // "점수: 12" (문자열이 앞이라 연결)
System.out.println("점수: " + (a + b)); // "점수: 3" (괄호로 계산 먼저)
// escape 문자
System.out.println("이름\t나이"); // 탭으로 열 맞추기
System.out.println("경로: C:\\java\\bin"); // \ 하나를 출력하려면 \\
System.out.println("그는 \"자바\"라고 말했다.");
}
}
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
print와 println의 차이가 단순히 줄바꿈뿐일까?
출력 자체는 줄바꿈 차이뿐이지만, 실무에서는 성능 차이가 생깁니다. 콘솔 출력은 느린 작업이라 반복문에서 println을 수만 번 부르면 눈에 띄게 느려집니다. 그래서 대량 출력은 StringBuilder에 모았다가 한 번에 출력합니다.
printf의 %d와 %s를 잘못 쓰면 언제 문제가 드러나나?
컴파일이 아니라 실행할 때입니다. 형식 문자열은 런타임에 해석되므로 타입이 안 맞으면 IllegalFormatConversionException이 납니다. 컴파일러가 못 잡아 주는 만큼 직접 확인해야 하는 부분입니다.
💼 실무·코딩테스트에서는코딩테스트에서 출력이 많은 문제는 System.out.println만으로 시간 초과가 나기도 합니다. BufferedWriter나 StringBuilder로 모아서 한 번에 출력하는 것이 정석 대응입니다. 실무에서는 콘솔 출력 대신 로거(Logger)를 쓰는데, 레벨별로 끄고 켤 수 있기 때문입니다.
TIP실습과제 11번 달력 출력이나 9번 마방진처럼 격자로 값을 찍어야 할 때는 printf("%3d", n)로 자리수를 고정하면 열이 깔끔하게 맞습니다.
11
키보드 입력 — Scanner와 InputStreamReader
ScannerSystem.innextIntnextLine
한 줄 요약System.in은 키보드 입력을 바이트로 받기 때문에, Scanner(또는 InputStreamReader)로 감싸서 nextInt()·nextLine() 같은 메서드로 원하는 타입으로 꺼내 쓴다.
쉽게 말하면System.in은 키보드에서 오는 바이트 물줄기예요. 그대로 마시면(숫자 코드라) 뭐가 뭔지 모르니, Scanner라는 정수기를 연결해서 "정수로 주세요(nextInt)", "한 줄로 주세요(nextLine)"라고 주문하는 겁니다.
왜 감싸야 하는가
System.in은 입력된 키 값을 바이트 정보로 전달합니다. 바이트를 문자 정보로 변환해야 어떤 글자가 입력됐는지 판단할 수 있으므로, 이를 대신해 주는 Scanner(JDK 1.5부터)나 InputStreamReader로 감싸서 사용합니다.
Scanner의 주요 메서드
next()는 공백 전까지 한 단어를 String으로, nextLine()은 한 줄 전체를 String으로 반환합니다. nextInt()·nextLong()·nextDouble()은 각각 해당 타입으로 변환해 줍니다. 사용하려면 import java.util.Scanner;가 필요하고 new Scanner(System.in)으로 객체를 만듭니다.
nextInt() 다음 nextLine()이 건너뛰는 함정
nextInt()는 숫자만 읽고 엔터(줄바꿈 문자)를 버퍼에 남겨둡니다. 그래서 바로 뒤에 nextLine()을 호출하면 남아 있던 엔터를 읽고 빈 문자열을 반환해 버립니다. 해결책은 숫자를 읽은 뒤 sc.nextLine();을 한 번 더 호출해 버퍼를 비우거나, 처음부터 전부 nextLine()으로 받아 Integer.parseInt()로 변환하는 것입니다.
InputStreamReader 방식
new InputStreamReader(System.in)은 바이트 스트림을 문자 스트림으로 바꿔 주는 다리 역할입니다. read()로 문자 하나씩 읽을 수 있고, 보통 BufferedReader로 한 번 더 감싸 readLine()으로 한 줄씩 읽습니다. Scanner보다 빠르지만 문자열로만 읽히므로 직접 형 변환해야 하고 IOException 예외 처리가 필요합니다(🛠️ IO 카드에서 이어집니다).
JAVAInputTest.java
import java.util.Scanner;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;
public class InputTest {
public static void main(String[] args) throws IOException {
// ---- 방법 1: Scanner (가장 간단, 타입별 메서드 제공) ----
Scanner sc = new Scanner(System.in);
System.out.print("나이를 입력하세요: ");
int age = sc.nextInt(); // 숫자만 읽고 엔터는 버퍼에 남는다
sc.nextLine(); // ★ 남은 엔터를 비워 준다 (안 하면 아래가 건너뛰어짐)
System.out.print("이름을 입력하세요: ");
String name = sc.nextLine(); // 한 줄 전체(공백 포함)
System.out.printf("%s님은 %d살입니다.%n", name, age);
// sc.close()를 여기서 하면 System.in까지 닫혀 아래 방법 2가 IOException(Stream closed)으로 실패한다
// ---- 방법 2: InputStreamReader + BufferedReader (빠르지만 형 변환 필요) ----
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
System.out.print("숫자 하나: ");
int n = Integer.parseInt(br.readLine()); // 항상 String으로 오므로 직접 변환
System.out.println("입력한 수의 제곱: " + (n * n));
sc.close(); // 입력을 다 받은 맨 끝에서 한 번만 닫는다(System.in도 함께 닫힘)
}
}
핵심 정리
System.in은 바이트라서 Scanner나 InputStreamReader로 감싸 문자로 바꿔 쓴다.
nextInt() 뒤에 nextLine()을 쓰면 엔터 때문에 건너뛴다 → nextLine()을 한 번 더 호출해 비운다.
BufferedReader는 빠르지만 문자열만 주므로 Integer.parseInt()로 변환하고 예외 처리가 필요하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
Scanner의 nextInt() 뒤에 nextLine()이 건너뛰어지는 이유를 설명해보세요.
nextInt()가 숫자만 읽고 엔터(개행 문자)를 입력 버퍼에 남겨 두기 때문입니다. 다음 nextLine()이 그 남은 개행을 읽고 빈 문자열로 끝나 버립니다. 그래서 숫자를 읽은 뒤 nextLine()을 한 번 버리는 처리가 필요합니다.
Scanner 대신 BufferedReader를 쓰는 이유는?
속도 때문입니다. Scanner는 입력을 파싱하며 정규식을 쓰기 때문에 느립니다. BufferedReader는 버퍼에 모아 한 번에 읽어 훨씬 빠른 대신, 항상 문자열로 받아 Integer.parseInt()로 직접 변환해야 합니다.
💼 실무·코딩테스트에서는코딩테스트에서 입력이 많으면 Scanner로는 시간 초과가 납니다. BufferedReader + StringTokenizer 조합이 표준 대응이에요. 실무에서는 콘솔 입력을 쓸 일이 거의 없지만, 버퍼링이 왜 빠른가라는 원리는 파일·네트워크 IO에서 그대로 반복됩니다.
TIP실습과제의 야구게임·달력(생일 입력)처럼 사용자 입력을 반복해서 받는 문제에서는 Scanner 객체를 메서드마다 새로 만들지 말고 하나만 만들어 재사용하세요. 여러 개 만들면 버퍼가 꼬여 입력이 씹히는 일이 생깁니다. 그리고 System.in은 한 번 닫으면 다시 열 수 없습니다 — sc.close()는 감싸고 있는 System.in까지 닫기 때문에, 그 뒤에 새 Scanner나 BufferedReader로 키보드를 읽으면 NoSuchElementException·IOException: Stream closed가 납니다. 키보드 입력은 프로그램 맨 끝에서 한 번만 닫으세요.
🧱 2. 객체지향 프로그래밍
클래스·객체·메서드라는 용어 정리에서 시작해 static과 메모리 구조, 생성자와 오버로딩, String과 배열, 상속·은닉성·다형성이라는 OOP 3대 개념, 추상클래스·인터페이스·제네릭까지 — 자바의 중심이 되는 단원입니다.
01
클래스 · 객체 · 인스턴스와 용어 정리
classinstance멤버필드지역변수new
한 줄 요약클래스는 설계도이고 new로 그 설계도를 heap 메모리에 실제로 찍어낸 것이 인스턴스이며, 만들어진 객체의 주소(reference)를 변수에 담아 그 주소로 멤버에 접근한다.
쉽게 말하면클래스는 붕어빵 틀, 객체(인스턴스)는 그 틀로 구운 붕어빵이에요. 틀 하나로 붕어빵 100개를 만들 수 있고, 붕어빵마다 팥·슈크림처럼 내용물(멤버필드 값)이 다를 수 있습니다. A a = new A();는 "붕어빵을 하나 굽고(new), 그 붕어빵이 놓인 자리 번호(주소)를 a라는 쪽지에 적어둔다"는 뜻입니다.
클래스 · 메서드 · 객체
클래스(Class)는 틀·설계도이며 사용자 정의 타입(User-defined type)입니다. 메서드(Method)는 그 안에 정의된 기능이고, 객체(Object)는 new로 heap에 만들어진 실체이고, 참조변수는 그 객체의 reference(주소)를 담은 변수입니다. 둘은 다른 것이며, 우리는 참조변수에 담긴 주소를 통해 객체의 멤버필드와 메서드를 사용합니다. 인스턴스(instance)는 "어떤 클래스로부터 만들어진 객체"를 뜻하며, 객체를 생성하는 것을 인스턴스화라고 부릅니다.
A a = new A();를 조각내서 읽기
A(맨 앞) — 클래스 타입. a — 객체명(변수)이며 실제로는 주소를 담는 쪽지. = — 오른쪽에서 만들어진 주소를 왼쪽에 대입. new — heap 영역에 객체를 실제로 만들고 그 주소를 돌려주는 예약어. A() — 생성자 호출(클래스 내에서는 생성자, 밖에서 보면 초기화 코드). 이 다섯 조각을 분리해서 읽으면 "만들고(new) → 주소를 받아서(=) → 쪽지에 적는다(a)"가 됩니다.
변수의 4가지 위치와 이름
클래스 안에 선언된 변수를 통틀어 멤버필드(member field)라 하고, 그중 static이 붙으면 클래스 변수(전역), 안 붙으면 인스턴스 변수입니다. 메서드 안(중괄호 블록 안)에 선언된 것은 지역변수(local variable), 메서드 괄호 안에서 값을 받는 변수는 매개변수(parameter)입니다. 그리고 클래스명과 같은 이름에 리턴 타입이 없는 것이 생성자(Constructor)입니다.
JAVATermMain.java
public class TermMain { // ← 클래스 (설계도, Pascal 명명법)
int age = 20; // ┐ 인스턴스 변수 (객체마다 따로 생김)
private String str; // ┘
static int number; // ← static이 붙으면 클래스 변수(모두가 공유)
// 위의 셋을 통틀어 "멤버필드"라고 부른다
public TermMain() { } // ← 생성자 (클래스명과 같고 리턴 타입이 없다)
public void method(int param) { // ← param : 매개변수(parameter)
for (int i = 0; i < 5; i++) { // ← i : 지역변수 (이 블록 안에서만 존재)
}
}
public static void main(String[] args) {
TermMain t = new TermMain(); // 인스턴스화: heap에 객체 생성 → 주소를 t에 저장
t.method(10); // 10 : argument(실인수) → param 으로 전달됨
System.out.println(TermMain.number); // static은 객체 없이 클래스명으로 접근
}
}
핵심 정리
클래스 = 설계도(틀), 인스턴스 = new로 heap에 만들어진 실체, 객체 변수 = 그 주소를 담은 쪽지.
new로 만든 객체(인스턴스)는 heap 영역에 덩어리로 할당되고 reference(주소)를 통해서만 사용할 수 있다. 클래스 정보(설계도·메서드 코드·static 필드)는 heap이 아니라 Method Area에 한 번 올라간다.
멤버필드(인스턴스 변수/클래스 변수) vs 지역변수 vs 매개변수 — 선언 위치로 구분한다.
생성자는 클래스명과 이름이 같고 리턴 타입이 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
클래스와 객체의 관계를 설계도 비유 말고 다른 말로 설명해보세요.
클래스는 타입(자료형)이고 객체는 그 타입의 값입니다. int a = 5;에서 int가 클래스, 5가 객체에 해당하는 셈이죠. 그래서 Student s = new Student();는 "Student라는 새 자료형의 값을 하나 만들어 s에 담는다"는 뜻입니다.
인스턴스와 객체를 굳이 구분한다면 어떤 차이인가?
실무에선 거의 같은 말로 쓰지만, 굳이 나누면 객체는 만들어진 실체 자체를, 인스턴스는 "어느 클래스로부터 만들어졌는가"라는 관계를 강조하는 말입니다. "s는 객체다"보다 "s는 Student의 인스턴스다"가 자연스러운 이유입니다.
💼 실무·코딩테스트에서는객체지향의 출발점이라 면접 첫 질문으로 자주 나옵니다. 정의를 외워 말하기보다 "왜 클래스로 묶는가"를 답할 수 있어야 합니다 — 관련된 데이터와 그 데이터를 다루는 기능을 한 덩어리로 묶어 변경 범위를 좁히기 위해서입니다.
TIP용어가 헷갈릴 때는 "어디에 선언됐는지"만 보면 됩니다. 클래스 바로 아래면 멤버필드, 메서드 중괄호 안이면 지역변수, 메서드 소괄호 안이면 매개변수입니다.
02
Object 클래스와 4대 메서드
ObjecttoStringhashCodeequalsgetClass
한 줄 요약모든 클래스는 자동으로 Object를 상속하므로 getClass()·toString()·hashCode()·equals() 네 메서드를 물려받으며, 내 클래스를 값으로 비교하려면 equals와 hashCode를 오버라이드해야 한다.
쉽게 말하면Object는 모든 클래스의 시조(始祖)예요. 자바에서 만드는 모든 클래스는 extends를 안 써도 자동으로 Object의 자손이 되고, 그래서 어떤 객체든 toString()이나 equals()를 부를 수 있습니다. 다만 물려받은 기본 equals()는 "주민번호(주소)가 같은가"만 보기 때문에, "이름과 나이가 같으면 같은 사람"으로 보고 싶다면 내가 다시 정의(오버라이드)해야 합니다.
모든 클래스의 최상위 부모
Object 클래스는 모든 클래스 계층구조의 최상위에 있습니다. 내가 만든 클래스에 extends를 쓰지 않아도 컴파일러가 자동으로 extends Object를 붙이므로, 모든 클래스는 Object의 메서드를 상속받습니다.
getClass() · toString()
getClass()는 그 객체가 어느 클래스로 만들어졌는지 런타임 클래스 정보를 반환합니다(class java.lang.String 형태). toString()은 객체를 문자열로 표현하며, 오버라이드하지 않으면 클래스위치@16진수해시코드 형태(com.hk.UserDefineClass@166afb3)로 출력됩니다. System.out.println(객체)는 내부적으로 toString()을 호출하므로, 객체를 찍었을 때 이상한 문자열이 나오면 toString()을 오버라이드하지 않은 것입니다.
hashCode() · equals()
hashCode()는 HashMap·HashSet 같은 해시 자료구조가 객체를 어느 칸에 넣을지 빨리 고르기 위한 보조 정수입니다. 고유값이 아니어서 서로 다른 객체가 같은 hashCode를 가질 수도 있습니다(재정의하지 않은 기본 구현은 보통 객체마다 다른 값을 줄 뿐입니다). equals()의 기본 구현(Object.equals)은 hashCode를 쓰지 않고 ==와 똑같이 주소를 비교하므로, 내용이 같은 두 객체를 각각 new로 만들면 false가 나옵니다. 단 String은 equals를 "문자를 한 글자씩 비교"하도록 재정의해 두었고(hashCode도 내용 기준으로 재정의), 그래서 값이 같으면 true가 나옵니다.
내 클래스를 값으로 비교하려면
equals를 오버라이드할 때는 hashCode도 함께 오버라이드해야 합니다(API 문서에도 명시된 규약). "같은 객체는 같은 해시코드를 가져야 한다"는 계약이 깨지면 HashMap·HashSet에 넣었을 때 같은 값인데도 다른 칸에 저장되어 중복이 생깁니다.
JAVAObjectMethod.java
class UserDefineClass {
String name;
UserDefineClass(String name) { this.name = name; }
// 오버라이드하지 않으면 "클래스명@해시코드"가 찍힌다
@Override
public String toString() { return "UserDefineClass(" + name + ")"; }
// 값으로 비교하고 싶다면 equals와 hashCode를 "같이" 재정의한다
@Override
public boolean equals(Object obj) {
if (this == obj) return true; // 주소가 같으면 당연히 같다
if (!(obj instanceof UserDefineClass)) return false;
return this.name.equals(((UserDefineClass) obj).name);
}
@Override
public int hashCode() { return name.hashCode(); } // 값 기준 해시코드
}
public class ObjectMethod {
public static void main(String[] args) {
String s = new String("java");
System.out.println(s.getClass()); // class java.lang.String
UserDefineClass d1 = new UserDefineClass("A");
UserDefineClass d2 = new UserDefineClass("A");
System.out.println(d1); // toString() 자동 호출
System.out.println(d1 == d2); // false — 주소가 다르다
System.out.println(d1.equals(d2)); // true — 오버라이드했으므로 값 비교
System.out.println(d1.hashCode() == d2.hashCode()); // true
}
}
핵심 정리
모든 클래스는 Object를 자동 상속하므로 4대 메서드를 항상 쓸 수 있다.
toString()을 재정의하지 않으면 클래스명@16진수해시코드가 출력된다.
기본 Object.equals()는 ==와 같은 주소 비교다. hashCode는 해시 자료구조용 보조 값이지 고유값이 아니다.
String은 equals(문자 비교)·hashCode(내용 기준)를 이미 재정의해 두었다.
equals를 재정의하면 hashCode도 반드시 함께 재정의한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
모든 클래스가 Object를 상속받는다는 게 실제로 무엇을 보장하나?
어떤 객체든 toString()·equals()·hashCode()·getClass()를 부를 수 있다는 것입니다. 덕분에 System.out.println(아무객체)가 항상 동작하고, 컬렉션이 타입을 몰라도 담고 비교할 수 있습니다.
equals()를 재정의하면 hashCode()도 함께 재정의하라는 이유는?
HashMap·HashSet이 hashCode로 먼저 자리를 찾고 equals로 확인하기 때문입니다. equals로는 같은데 hashCode가 다르면 서로 다른 칸에 저장되어 중복이 걸러지지 않습니다. "같은 객체는 같은 해시값"이라는 약속이 깨지는 것이죠.
💼 실무·코딩테스트에서는실무에서 DTO·엔티티를 만들면 equals/hashCode 재정의는 거의 필수입니다. 재정의를 빠뜨려 HashSet에 중복이 들어가는 버그가 흔하고, 면접에서도 그 이유를 자주 묻습니다. 요즘은 IDE 자동 생성이나 Lombok @EqualsAndHashCode를 쓰지만 원리는 알고 써야 합니다.
TIP이클립스·VS Code에는 equals/hashCode 자동 생성 기능이 있습니다(우클릭 → Source → Generate). 실무에서는 손으로 쓰기보다 자동 생성 후 필요한 필드만 체크하는 방식을 씁니다.
03
메서드의 구조 — 리턴타입 · parameter · argument
methodreturnparameterargument
한 줄 요약메서드는 접근제한자 static여부 리턴타입 메서드명(매개변수) 형태로 클래스 안에만 선언할 수 있고, 호출하는 쪽이 넘기는 실제 값이 argument, 받는 쪽 변수가 parameter다.
쉽게 말하면메서드는 자판기예요. 돈을 넣는 투입구가 매개변수(parameter), 실제로 넣은 1000원이 인수(argument), 나오는 음료가 리턴값입니다. 음료가 안 나오는 자판기(안내판만 켜지는)가 void고요. 그리고 자판기 안에 또 다른 자판기를 통째로 넣을 수는 없듯이, 메서드 안에 메서드를 정의할 수는 없습니다.
메서드 선언부를 조각내 읽기
public static void main(String[] args)를 조각내면 — public: 접근 제한자(어디서 접근 가능한가), static: static인가 non-static인가, void: 리턴 타입(돌려주는 값의 종류), main: 메서드명(카멜), (String[] args): 매개변수 목록. 이 중 리턴타입 → 메서드명 → (매개변수) 순서는 문법상 고정이고, 앞쪽의 제한자들끼리는 static public처럼 바꿔 써도 컴파일되지만 관례상public static 순서로 씁니다.
리턴 타입 4단계 작성법
① 원하는 반환 타입을 정한다(public int makeA()) → ② 그 타입의 변수를 선언·초기화한다(int i = 0;) → ③ 같은 타입으로 반환한다(return i;) → ④ 호출한 쪽에서 같은 타입 변수에 담는다(int m = makeA();). 리턴 타입에는 void(없음), 기본타입, 참조타입이 올 수 있습니다.
parameter(매개변수) vs argument(인수)
선언부의 test(int x)에서 x가 parameter(매개변수·가인수)이고, 호출부의 test(10)에서 10이 argument(인수·실인수)입니다. 즉 매개변수는 그릇, 인수는 담기는 값입니다. 자바는 항상 값을 복사해서 전달(pass by value)합니다 — 기본타입은 값 자체가, 참조타입은 주소 값이 복사되어 전달됩니다(그래서 참조타입도 엄밀히는 pass by reference가 아닙니다) — 09. 기본타입 vs 참조타입에서 이어집니다.
메서드를 나누는 이유
반복적으로 수행되는 여러 문장을 하나의 메서드로 묶어 두면, 같은 코드를 여러 번 쓰지 않아도 되고 수정도 한 곳만 하면 됩니다. 실습과제에서 "약수의 합을 구하는 메서드"를 만들어 두면 친화수와 완전수 문제에 그대로 재사용할 수 있는 것이 대표적인 예입니다.
JAVAMethodTest.java
public class MethodTest {
// 리턴 타입이 int → 반드시 int 값을 return 해야 한다
public static int sum(int a, int b) { // a, b : parameter(매개변수)
int result = a + b; // ② 반환 타입과 같은 변수 선언
return result; // ③ 그 변수를 반환
}
// 리턴 타입이 void → return 값이 없다 (return; 으로 중간 종료는 가능)
public static void printLine(String msg) {
if (msg == null) return; // 값 없이 메서드만 빠져나가기
System.out.println("== " + msg + " ==");
}
// 배열(참조타입)도 리턴할 수 있다
public static int[] makeArray(int size) {
int[] arr = new int[size];
for (int i = 0; i < size; i++) arr[i] = i * i;
return arr;
}
public static void main(String[] args) {
int m = sum(10, 20); // 10, 20 : argument(인수) → ④ 같은 타입에 담는다
printLine("결과");
System.out.println(m); // 30
int[] squares = makeArray(5);
for (int s : squares) System.out.print(s + " "); // 0 1 4 9 16
}
}
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
parameter와 argument를 구분해서 설명해보세요.
parameter(매개변수)는 메서드를 정의할 때 선언한 변수, argument(인자)는 호출할 때 실제로 넘기는 값입니다. void f(int x)의 x가 parameter, f(5)의 5가 argument입니다.
리턴 타입이 void인 메서드에서 return을 쓸 수 있나?
쓸 수 있습니다. 값 없이 return;만 쓰면 메서드를 즉시 종료합니다. 조건이 안 맞을 때 일찍 빠져나오는 early return 패턴에 씁니다 — 중첩된 if를 펴는 데 유용합니다.
💼 실무·코딩테스트에서는실무에서 메서드 하나는 한 가지 일만 하도록 짜는 것이 원칙입니다. 매개변수가 넷을 넘어가면 객체로 묶으라는 신호로 봅니다. 코딩테스트에서도 메서드로 쪼개면 디버깅이 훨씬 쉬워집니다 — 한 덩어리로 쓰면 어디가 틀렸는지 찾기 어렵습니다.
TIP"이 메서드가 뭘 하는가"를 한 문장으로 말할 수 없다면 메서드가 너무 큰 것입니다. 메서드 하나에 기능 하나가 원칙이고, 실습과제를 풀 때도 "약수 구하기 / 합 구하기 / 판별하기"를 각각 메서드로 나누면 코드가 훨씬 단순해집니다.
04
접근 제한자 4가지
privatedefaultprotectedpublic
한 줄 요약private(같은 클래스) → default(같은 패키지) → protected(같은 패키지 + 상속받은 자식) → public(어디서나) 순으로 접근 범위가 넓어지며, 데이터를 감추기 위해 필드는 private으로 두는 것이 기본이다.
쉽게 말하면접근 제한자는 집의 출입 권한이에요. private은 내 방(나만 들어감), default는 우리 집(같은 패키지 식구만), protected는 우리 집 + 분가한 자식(상속받은 클래스는 밖에 있어도 들어옴), public은 공원(누구나)입니다.
4가지 범위
private (-): 같은 클래스 안에서만 접근·참조 가능. (default): 아무것도 안 쓴 상태로, 같은 패키지 안에서만 접근 가능. protected (#): 같은 패키지에서 접근 가능하고, 상속받은 자식 클래스라면 다른 패키지에서도 접근 가능. 단 다른 패키지의 자식은 상속받은 자기 자신(this·자식 타입 객체)을 통해서만 쓸 수 있고, 부모 타입 객체를 따로 만들어 new Parent().protectedVal처럼 접근하는 것은 안 됩니다. public (+): 어디서나 접근 가능. 괄호 안의 기호(-, #, +)는 클래스 다이어그램(UML)에서 쓰는 표기입니다.
왜 감추는가 — 은닉화의 출발점
접근 제한자의 목적은 외부로부터 데이터를 보호하고, 내부적으로만 쓰는 부분을 감추는 것입니다. 필드를 public으로 열어 두면 외부에서 obj.age = -100;처럼 말도 안 되는 값을 넣을 수 있습니다. 필드를 private으로 막고 setAge() 메서드로만 값을 넣게 하면 그 안에서 유효성 검사를 할 수 있습니다 — 이것이 은닉화(Encapsulation)입니다.
실전 규칙
일반적으로 멤버필드는 private, 메서드는 public으로 시작하고 필요할 때만 범위를 넓힙니다. 상속 구조에서 자식이 부모의 필드에 직접 접근해야 한다면 protected를 씁니다. 참고로 private 메서드는 오버라이딩할 수 없습니다 — 자식이 볼 수조차 없기 때문입니다.
JAVAClassDiagram.java
public class ClassDiagram {
public int publicVal; // + : 어디서나
protected int protectedVal; // # : 같은 패키지 + 상속받은 자식
int defaultVal; // 같은 패키지만 (아무것도 안 씀)
private int privateVal; // - : 이 클래스 안에서만
public void publicMethod() { }
protected void protectedMethod() { }
void defaultMethod() { }
private void privateMethod() { }
}
// 실전 패턴: 필드는 private, 접근은 메서드로 (getter/setter)
class Student {
private int age; // 외부에서 직접 못 건드린다
public void setAge(int age) {
if (age < 0) { // ← private이라서 이런 검증이 가능해진다
System.out.println("나이는 음수일 수 없습니다.");
return;
}
this.age = age;
}
public int getAge() { return age; }
}
protected는 상속 관계라면 다른 패키지에서도 접근 가능하다는 점이 default와의 차이.
필드는 private + 메서드로 접근하는 것이 은닉화의 기본 형태다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
필드를 private으로 막고 getter/setter로 여는 것이 무슨 의미가 있나?
통로를 하나로 만들어 검증을 걸 수 있게 됩니다. 필드가 public이면 어디서든 아무 값이나 넣을 수 있지만, setter를 거치면 "나이는 음수가 될 수 없다" 같은 규칙을 한 곳에서 강제할 수 있습니다. 나중에 규칙이 바뀌어도 그 메서드만 고치면 됩니다.
default(제한자 없음)와 protected의 차이는?
default는 같은 패키지 안에서만, protected는 같은 패키지 + 다른 패키지의 자식 클래스까지 접근할 수 있습니다. 상속을 염두에 두고 물려줄 것이면 protected, 패키지 내부 구현이면 default입니다.
💼 실무·코딩테스트에서는실무 원칙은 "가능한 한 좁게"입니다. 처음엔 private으로 두고 필요할 때만 넓히죠 — 한 번 public으로 열면 누가 쓰는지 알 수 없어 되돌리기 어렵기 때문입니다. 이것이 라이브러리 설계에서 특히 중요합니다.
TIP클래스 자체에는 public과 default만 쓸 수 있습니다(중첩 클래스 제외). 그리고 한 .java 파일에는 public 클래스가 하나만 올 수 있고, 그 이름은 파일명과 같아야 합니다.
05
static vs non-static과 메모리 3영역
staticMethod AreaStackHeap
한 줄 요약static 멤버는 프로그램 시작과 함께 Method Area에 올라가 객체 없이 클래스명으로 쓰지만, non-static 멤버는 new로 인스턴스를 만들어야 Heap에 생기므로 static 메서드에서 non-static을 바로 쓸 수 없다.
쉽게 말하면static은 건물 로비에 붙은 공용 게시판이에요. 건물이 문을 여는 순간(프로그램 시작) 이미 있고 누구나 볼 수 있습니다. non-static은 각 세대의 냉장고라서, 세대가 실제로 입주(new)해야 생깁니다. 그래서 로비 게시판(static)에서 "301호 냉장고 속 우유"를 바로 꺼낼 수 없는 겁니다 — 301호가 아직 없을 수도 있으니까요.
static의 생명주기와 호출 방법
static 멤버는 그 클래스가 처음 로딩될 때(처음 사용되는 시점 — 예: 객체 생성, static 메서드 호출, static 필드 접근) Method Area에 한 번 할당되고, 보통 프로그램이 끝날 때까지 남아 있습니다. 프로그램 시작과 동시에 모든 클래스의 static이 한꺼번에 올라가는 것은 아닙니다. 객체를 생성하지 않고 클래스명.메서드명()으로 호출합니다. 반대로 non-static 멤버는 인스턴스를 생성할 때 만들어지며객체명.메서드명()으로 호출합니다.
핵심 규칙: static → non-static은 불가
static은 non-static을 직접 사용할 수 없습니다(객체를 생성하면 가능). 반대로 non-static은 static을 자유롭게 사용할 수 있습니다. 이유는 시점 차이입니다 — static이 메모리에 올라간 시점에는 인스턴스가 아직 존재하지 않을 수 있기 때문입니다. main이 static이라서 "main에서 일반 메서드를 바로 호출하면 에러가 나는" 익숙한 문제가 바로 이 규칙입니다.
메모리 3영역
Method Area(= static 영역): 클래스 정보, static 필드·메서드, 상수 풀을 저장합니다(여기서 말하는 상수 풀은 클래스마다 있는 런타임 상수 풀로, 문자열 리터럴 객체를 모아 두는 String pool과는 다릅니다 — String pool은 Java 7부터 Heap에 있습니다. 12. String pool 참고). 흔히 "static 메모리"라 부르지만 개념상 Method Area가 더 큰 범위입니다. Stack: 지역변수·매개변수, 메서드 호출 정보를 저장하며 LIFO 구조로 블록이 끝나면 사라집니다. Heap: new로 만든 모든 객체(인스턴스)와 배열이 저장되며, 참조가 없어지면 가비지 컬렉터가 정리합니다.
어디에 뭐가 저장되는지 한눈에
public static final int NUMBER = 10000; → Method Area. public int number = 10; → 객체가 만들어질 때 Heap 안에. main 안의 int i = 100; → Stack. new Student()의 결과 객체 → Heap, 그 객체를 가리키는 변수 → Stack.
JAVAStaticTest.java
public class StaticTest {
public static final int NUMBER = 10000; // Method Area (상수)
static int count = 0; // Method Area (모든 객체가 공유)
public int number = 10; // Heap (객체마다 따로)
public StaticTest() { count++; } // 객체가 만들어질 때마다 공유 카운트 증가
static void staticMethod() {
System.out.println("count = " + count); // OK: static → static
// System.out.println(number); // 에러! static → non-static 불가
StaticTest t = new StaticTest();
System.out.println(t.number); // 객체를 만들면 접근 가능
}
void instanceMethod() {
System.out.println(count); // OK: non-static → static 은 자유롭게 가능
System.out.println(number); // OK
}
public static void main(String[] args) { // main도 static이다
staticMethod(); // 클래스명 없이 바로 호출 가능
new StaticTest();
new StaticTest();
System.out.println(StaticTest.count); // 3 (staticMethod에서 1개 + 여기서 2개)
StaticTest obj = new StaticTest();
obj.instanceMethod(); // non-static은 객체를 통해서만
}
}
핵심 정리
static: 클래스가 처음 로딩(사용)될 때 Method Area에 한 번 할당, 클래스명.멤버로 접근, 모든 객체가 공유.
non-static: new 할 때 Heap에 생성, 객체명.멤버로 접근, 객체마다 별도.
static → non-static 직접 접근 불가(객체 생성 시 가능) / non-static → static 접근 가능.
Method Area = 클래스·static·상수 / Stack = 지역변수·매개변수 / Heap = new로 만든 객체.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
static 메서드에서 인스턴스 변수를 못 쓰는 이유를 메모리로 설명해보세요.
static은 클래스가 로딩될 때 이미 존재하지만, 인스턴스 변수는 new를 해야 힙에 생기기 때문입니다. static 메서드가 실행되는 시점에 인스턴스가 아직 없을 수도 있어서, 어느 객체의 값을 쓸지 정할 수 없습니다.
static을 남용하면 무엇이 문제가 되나?
모든 곳에서 공유되는 전역 상태가 되기 때문입니다. 어디서 값이 바뀌었는지 추적이 어렵고, 테스트가 서로 영향을 주며, 멀티스레드에서는 동기화 문제가 생깁니다. 그래서 static은 상태 없는 유틸리티나 상수에 주로 씁니다.
💼 실무·코딩테스트에서는메모리 3영역은 면접 단골입니다. "static 변수는 어디에 저장되나요?"(Method Area), "객체는?"(Heap), "지역변수는?"(Stack)을 즉답할 수 있어야 합니다. 실무에서는 static 남용이 테스트를 어렵게 만드는 주범이라 코드 리뷰에서 자주 지적됩니다.
TIP초보자가 가장 많이 만나는 에러 "Cannot make a static reference to the non-static method"가 정확히 이 규칙입니다. main에서 일반 메서드를 쓰려면 ① 그 메서드에 static을 붙이거나 ② main 안에서 객체를 만들어 호출하면 됩니다.
06
오버로딩(Overloading) — 같은 이름, 다른 매개변수
Overloading함수중복substring
한 줄 요약이름은 같지만 매개변수의 개수나 타입이 다른 메서드를 여러 개 만드는 것이 오버로딩이며, 리턴 타입만 다른 것은 오버로딩이 되지 않는다.
쉽게 말하면오버로딩은 같은 이름의 여러 창구예요. "출금"이라는 창구 이름은 하나인데, 통장을 내밀면 통장 출금, 카드를 내밀면 카드 출금으로 처리되는 식입니다. 무엇을 내미느냐(매개변수)에 따라 알아서 맞는 처리를 골라 줍니다.
오버로딩의 조건
① 메서드 이름이 같아야 하고 ② 매개변수의 개수 또는 타입이 달라야 합니다. 개수가 같다면 타입이 달라야 하고, 순서가 다른 것도 다른 것으로 인정됩니다. ③ 리턴 타입은 달라도 되지만, 리턴 타입만 다른 것은 오버로딩이 아닙니다 — 호출하는 쪽에서 어느 메서드를 부를지 구분할 방법이 없기 때문입니다.
왜 쓰는가
동일한 기능인데 입력이 다른 경우를 하나의 이름으로 묶어 사용자가 외울 이름을 줄여 줍니다. 자바 표준 API가 이 패턴을 적극적으로 씁니다 — String.substring(5)는 5번째부터 끝까지, String.substring(3, 5)는 3번째부터 5번째 앞까지를 잘라 주는 오버로딩된 메서드입니다. System.out.println()도 int·double·String·객체용이 각각 따로 있는 오버로딩 덩어리입니다.
오버로딩 vs 오버라이딩
이름이 비슷해 자주 헷갈립니다. 오버로딩(Overloading)은 같은 이름의 메서드를 매개변수만 달리해 여러 개 만드는 것이고(주로 같은 클래스 안에서 하지만, 자식이 부모에게 물려받은 메서드와 이름은 같고 매개변수가 다른 메서드를 추가하는 것도 오버로딩입니다), 오버라이딩(Overriding)은 상속 관계에서 부모의 메서드를 자식이 같은 형태로 다시 정의하는 것입니다(19. 오버라이딩과 다형성).
JAVAOverloadingTest.java
public class OverloadingTest {
// ① 매개변수 개수가 다르다
public int a(int b, int c) { return b + c; }
public int a(int b) { return b; }
// ② 개수가 같으면 타입이 달라야 한다
public int a(byte b) { return b; }
// ③ 리턴 타입은 달라도 된다 (단, 리턴 타입"만" 다른 건 불가)
public byte a(int b, int c, int d) { return 5; }
// public double a(int b) { return 0; } // ← 에러! 위 a(int b)와 매개변수가 같다
public static void main(String[] args) {
// 표준 API의 오버로딩 사례 — substring
String subStr = "getitbeauty";
String oneS = subStr.substring(5); // 5번 인덱스부터 끝까지 → "beauty"
String twoS = subStr.substring(3, 5); // 3번부터 5번 앞까지 → "it"
System.out.println(oneS + " : " + twoS);
// println 도 타입별로 오버로딩되어 있다
System.out.println(10); // println(int)
System.out.println(3.14); // println(double)
System.out.println("hello"); // println(String)
}
}
핵심 정리
오버로딩 = 같은 클래스(또는 물려받은 메서드와), 같은 이름, 매개변수의 개수/타입/순서가 다른 메서드들.
리턴 타입은 달라도 되지만, 리턴 타입만 다르면 오버로딩이 성립하지 않는다.
substring, println처럼 표준 API가 이 패턴을 많이 쓴다.
오버로딩(이름 같고 매개변수 차이) ≠ 오버라이딩(상속 관계, 재정의).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
오버로딩에서 '매개변수가 다르다'의 기준에 리턴 타입이 포함되지 않는 이유는?
호출하는 쪽만 보고는 어느 것을 부를지 결정할 수 없기 때문입니다. f(1)이라고만 썼을 때 리턴 타입이 int인지 double인지 알 수 없죠. 컴파일러는 인자의 개수·타입·순서만으로 판단합니다.
오버로딩이 없다면 코드가 어떻게 달라질까?
printInt, printString, printDouble처럼 이름을 전부 다르게 지어야 합니다. 실제로 오버로딩이 없는 언어들이 그렇게 합니다. System.out.println이 어떤 타입이든 받아 주는 것도 오버로딩 덕분입니다.
💼 실무·코딩테스트에서는실무에서 생성자 오버로딩이 너무 많아지면(매개변수 조합이 여러 개) 빌더 패턴으로 바꾸는 것이 정석입니다. new Pizza(true, false, true, 2)보다 Pizza.builder().cheese().size(2).build()가 훨씬 읽히니까요.
TIP생성자도 오버로딩할 수 있습니다 — 07. 생성자에서 "기본형 TV / 크기 지정 TV / 크기+색상 지정 TV"를 만드는 예제가 바로 생성자 오버로딩입니다.
07
생성자(Constructor)와 생성자 오버로딩
Constructordefault 생성자this()super()인스턴스 블록
한 줄 요약생성자는 클래스명과 이름이 같고 리턴 타입이 없으며 객체 생성 시 딱 한 번 호출되고, 매개변수를 달리해 여러 개 만들 수 있으며(생성자 오버로딩), 상속 관계에서는 자식이 만들어지기 전에 부모가 먼저 만들어진다.
쉽게 말하면생성자는 제품 출고 전 초기 세팅이에요. 새 휴대폰을 켜면 언어·시간대를 자동으로 맞춰 주듯, 객체가 만들어지는 순간 딱 한 번 실행되어 기본값을 채웁니다. 아무 세팅도 안 적어두면 공장 기본값(default 생성자)이 자동으로 들어가고, 내가 세팅을 하나라도 적으면 기본값은 더 이상 자동으로 만들어지지 않습니다.
생성자의 5가지 성질
① 외부에서 객체 생성 시 딱 한 번 호출된다. ② 클래스를 만들면 매개변수 없는 default 생성자가 자동 생성된다(단, 생성자를 하나라도 직접 만들면 자동 생성되지 않는다). ③ 접근제한자 + 클래스명으로 구성되고 이름이 클래스명과 같다. ④ 리턴 타입이 없다(void도 안 쓴다). ⑤ 부모의 생성자는 물려받지 못하며 오버라이딩도 금지된다.
자생부생 — 자식이 생성되려면 부모가 먼저
자식을 생성하려면 부모(부분)가 먼저 생성되어야 합니다 — 이것을 줄여 "자생부생"이라 부릅니다(글자 순서와 달리 실제 초기화는 부모가 먼저). 실행 순서는 ① new Child()로 자식 생성자가 호출되고 ② 그 첫 줄의 super()가 부모 생성자를 부르며 ③ 부모 생성자 본문이 끝까지 실행된 뒤 ④ 돌아와서 자식 생성자의 나머지가 실행됩니다. 부모 객체가 따로 하나 더 생기는 것은 아니고, 자식 객체 안의 부모 부분이 먼저 초기화되는 것입니다. 명시적으로 쓰지 않으면 컴파일러가 자식 생성자 첫 줄에 super();를 자동으로 넣습니다. 부모 생성자를 골라 부르려면 super(값)을, 같은 클래스의 다른 생성자를 부르려면 this(값)을 씁니다 — 둘 다 반드시 첫 줄에 와야 합니다.
생성자 오버로딩
이름은 클래스명으로 고정이므로 매개변수를 달리해 여러 개 만듭니다. 아래 Television 예제처럼 "기본형 / 크기만 지정 / 크기+색상 지정"을 각각 만들어 두면, 사용하는 쪽에서 필요한 정보만 넘겨 객체를 만들 수 있습니다. 중복 코드를 줄이려면 this(x, "검정색")처럼 다른 생성자를 호출하는 방식이 좋습니다.
인스턴스 블록: 생성자보다 먼저 실행
클래스 안에 이름 없이 { }만 쓴 블록을 인스턴스 블록이라 하며, 객체가 생성될 때 생성자보다 먼저 실행됩니다. 여러 생성자에서 공통으로 해야 할 초기화를 한 곳에 모을 때 씁니다. 실행 순서는 static 블록(최초 1회) → 인스턴스 블록 → 생성자입니다.
JAVATelevision.java + Test.java
public class Television {
int size = 0;
String color = "검정색";
{ // 인스턴스 블록: 이름이 없는 { } — 생성자보다 먼저 실행된다
System.out.println("TV 생산을 시작합니다.");
}
Television() { // ① 매개변수 없는 생성자
this(20, "검정색"); // 다른 생성자 호출 — 반드시 첫 줄
}
Television(int x) { // ② 크기만 받는 생성자
this(x, "검정색");
}
Television(int x, String y) { // ③ 크기 + 색상 (실제 초기화는 여기 한 곳에서)
size = x;
color = y;
System.out.println(size + "인치 " + color + " TV 제작완료");
}
}
public class Test {
public static void main(String[] args) {
Television a = new Television(); // 20인치 검정색
Television b = new Television(30); // 30인치 검정색
Television c = new Television(40, "은색"); // 40인치 은색
}
}
핵심 정리
생성자는 클래스명과 같은 이름 + 리턴 타입 없음 + 객체 생성 시 한 번 호출.
생성자를 하나라도 직접 만들면 default 생성자는 더 이상 자동 생성되지 않는다.
부모 생성자는 상속되지 않으며 오버라이딩할 수 없다. 자식을 생성하려면 부모 부분이 먼저 생성된다(자생부생: super() → 부모 생성자 → 자식 생성자 나머지).
super()(부모 생성자)·this()(같은 클래스의 다른 생성자)는 반드시 첫 줄에 온다.
실행 순서: static 블록 → 인스턴스 블록 → 생성자.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
생성자를 하나라도 직접 만들면 기본 생성자가 사라지는 이유는?
컴파일러가 기본 생성자를 자동으로 넣어 주는 것은 "아무 생성자도 없을 때"만이기 때문입니다. 직접 만들었다는 건 객체 생성 방식을 통제하겠다는 의도로 보고, 컴파일러가 더 이상 개입하지 않습니다. 그래서 기본 생성자도 필요하면 직접 추가해야 합니다.
생성자와 일반 메서드의 결정적인 차이 두 가지는?
① 이름이 클래스명과 같아야 하고 리턴 타입이 없습니다. ② new로 객체를 만들 때 딱 한 번 자동 호출되며, 나중에 다시 부를 수 없습니다. 즉 객체의 초기 상태를 보장하는 자리입니다.
💼 실무·코딩테스트에서는실무에서 생성자는 "이 객체가 존재하려면 반드시 있어야 할 값"을 받는 자리입니다. 필수 값을 생성자로 강제하면 불완전한 객체가 만들어지는 것을 원천 차단할 수 있죠. 선택적인 값은 setter나 빌더로 받는 것이 일반적인 구분입니다.
TIP"기본 생성자가 없다"는 컴파일 에러는 대부분 매개변수 있는 생성자만 만들어 놓고 new 클래스()로 호출했을 때 납니다. 이럴 땐 매개변수 없는 생성자를 하나 더 만들어 주면 됩니다.
08
package와 import
packageimportjava.lang
한 줄 요약package는 비슷한 클래스를 묶는 물리적 폴더 구조이고 import는 다른 패키지의 클래스를 짧은 이름으로 쓰게 해 주는 선언이며, java.lang만 예외적으로 import 없이 쓸 수 있다.
쉽게 말하면package는 서류함의 서랍 이름이고 import는 "이 서랍에서 꺼내 쓸게요"라는 메모예요. 메모를 붙여 두면 매번 "인사팀-3층-서랍B의 김철수"라고 길게 말하지 않고 그냥 "김철수"라고 부를 수 있습니다.
package
비슷한 클래스들의 체계적인 묶음이자 물리적 디렉터리입니다. package com.hankyung.sales;라고 선언하면 실제 폴더도 com/hankyung/sales/ 구조여야 합니다. 패키지명은 전부 소문자로 쓰고, 보통 회사 도메인을 거꾸로 쓴 형태를 씁니다. 패키지 선언은 파일의 맨 첫 줄에 딱 하나만 올 수 있습니다.
import
컴파일러에게 "이 소스에서 쓰는 클래스가 어느 패키지 소속인지"를 알려 주는 선언입니다. 컴파일 시 컴파일러가 import 정보를 보고 모든 클래스 이름 앞에 패키지명을 자동으로 붙여 줍니다. import 패키지명.클래스명;으로 하나만 가져오거나 import 패키지명.*;로 그 패키지 전체를 가져올 수 있습니다. 위치는 package문 다음, 클래스 선언 이전이고 여러 번 쓸 수 있습니다.
java.lang은 왜 import가 필요 없나
java.lang은 자바의 핵심 패키지로 Object·String·Math·System·Wrapper 클래스 등이 들어 있습니다. 너무 자주 쓰이기 때문에 컴파일러가 자동으로 import해 줍니다. 그래서 String이나 System.out.println은 아무 선언 없이 바로 쓸 수 있는 반면, java.util의 Scanner·ArrayList는 직접 import해야 합니다.
JAVAPackageTest.java
package hk.edu20260803.day02; // ① 맨 첫 줄, 폴더 구조와 일치해야 한다
import java.util.Scanner; // ② 클래스 하나만 가져오기
import java.util.ArrayList;
// import java.util.*; // 이렇게 패키지 전체를 가져올 수도 있다
import java.io.IOException; // import는 여러 번 쓸 수 있다
public class PackageTest {
public static void main(String[] args) {
// java.lang 소속 — import 없이 바로 사용 가능
String s = "hello";
System.out.println(Math.max(3, 7));
// java.util 소속 — 위에서 import 했기 때문에 짧은 이름으로 사용 가능
Scanner sc = new Scanner(System.in);
ArrayList<String> list = new ArrayList<>();
list.add(s);
// import를 안 했다면 이렇게 전체 이름을 써야 한다
java.util.Date now = new java.util.Date();
System.out.println(list + " " + now);
}
}
핵심 정리
package = 클래스 묶음이자 실제 폴더 구조, 소문자로 쓰고 파일 맨 첫 줄에 하나만.
import = 다른 패키지 클래스를 짧은 이름으로 쓰기 위한 선언, package문 뒤·클래스 선언 앞, 여러 개 가능.
java.lang(Object·String·Math·System·Wrapper)은 자동 import되어 선언이 필요 없다.
import 패키지.*는 그 패키지의 클래스만 가져오며, 하위 패키지까지 가져오지는 않는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
패키지 이름을 도메인 역순(com.company.project)으로 짓는 관례의 이유는?
전 세계에서 이름이 겹치지 않게 하기 위해서입니다. 도메인은 이미 유일성이 보장되어 있으니, 그걸 뒤집어 쓰면 다른 회사 라이브러리와 클래스명이 같아도 충돌하지 않습니다. 이름이 곧 전역 고유 주소 역할을 하는 셈입니다.
import는 무엇을 하는 것이고, 안 쓰면 어떻게 되나?
클래스의 전체 이름을 매번 안 써도 되게 해 주는 것일 뿐입니다. import 없이도 java.util.List list = new java.util.ArrayList();처럼 전체 경로를 쓰면 동작합니다. 즉 import는 성능이나 용량과 무관한 순수한 편의 문법입니다.
💼 실무·코딩테스트에서는실무에서 import *(와일드카드)는 대부분 금지됩니다. 어느 패키지에서 온 클래스인지 안 보이고, 두 패키지에 같은 이름의 클래스가 있으면 충돌하기 때문입니다(java.util.Date와 java.sql.Date가 대표적). IDE가 자동으로 필요한 것만 넣어 줍니다.
TIP이클립스에서 Ctrl+Shift+O(Organize Imports)를 누르면 필요한 import를 자동으로 추가하고 안 쓰는 것은 지워 줍니다. VS Code에서도 클래스명을 쓰면 자동 import를 제안합니다.
09
기본타입 vs 참조타입 — 값 전달과 주소 전달
pass by value주소 값 복사immutablemutable
한 줄 요약자바는 항상 값을 복사해서 전달(pass by value)한다 — 기본타입은 값 자체가, 참조타입은 주소 값이 복사되므로, 참조타입은 복사된 주소로 객체 내부(필드·원소)를 고치면 원본 객체가 바뀌지만, 매개변수에 다른 값을 대입하는 것은 어떤 타입이든 호출한 쪽에 영향이 없다.
쉽게 말하면기본타입을 넘기는 건 서류를 복사해서 주는 것이고, 참조타입을 넘기는 건 창고 열쇠를 복사해서 주는 것이에요. 복사한 서류에 낙서해도 원본은 멀쩡하지만, 복사한 열쇠로 창고에 들어가 물건을 바꾸면 원래 창고의 물건이 진짜 바뀝니다. 반대로 받은 열쇠를 다른 열쇠로 바꿔 끼우는 것(매개변수에 새 값 대입)은 내 손의 복사본만 바뀔 뿐이라 원래 주인에게는 아무 영향이 없습니다. String도 똑같은 열쇠지만, 창고 안 물건을 바꾸는 메서드가 아예 없어서(immutable) 원본이 바뀌는 일이 생기지 않을 뿐입니다.
두 타입의 저장 위치와 성질
기본타입 변수에는 값 자체가 저장되고(지역변수라면 Stack), 대입도 전달도 값 복사입니다. 참조타입은 실제 객체가 Heap에 저장되고 변수에는 그 주소가 담기며, 대입·전달하면 이 주소 값이 복사됩니다. 대부분의 객체는 내용을 바꿀 수 있지만(mutable), String·Wrapper처럼 바꾸는 메서드가 없는 불변(immutable) 객체도 있습니다. 참조타입에는 API 클래스, 배열, 사용자 정의 클래스가 있으며, 기본타입들을 묶어 새로운 타입을 만드는 역할을 합니다.
String도 예외가 아니다 — 대입은 원래 원본에 영향이 없다
메서드에 String을 넘기고 그 안에서 st = "안녕하세요";로 바꿔도 호출한 쪽의 값은 그대로입니다. 이건 String이 특별해서가 아니라, 매개변수에 새 값을 대입하는 것은 어떤 타입이든 복사본만 바꾸기 때문입니다(Student도 st = new Student(99)는 원본에 영향 없음 — 아래 코드의 replaceStudent). String이 다른 점은 내용을 바꾸는 메서드가 하나도 없다(immutable)는 것뿐이라, setId()처럼 복사된 주소로 원본 내용을 고칠 방법 자체가 없습니다. String을 "수정"하는 연산은 모두 새 객체를 만들어 반환합니다(12. String의 특징). 정리하면 자바의 전달 방식은 모든 타입이 pass by value(참조타입은 주소 값 복사) 하나뿐입니다.
실전에서 헷갈리는 지점
참조타입을 넘겨받은 메서드가 st.setId(45)처럼 필드를 바꾸면 원본이 바뀝니다. 하지만 st = new Student(45)처럼 매개변수 자체에 새 객체를 대입하면 원본은 바뀌지 않습니다 — 복사된 열쇠를 다른 창고 열쇠로 바꿔 낀 것일 뿐 원래 창고는 그대로이기 때문입니다.
JAVAPassTest.java
class Student {
int id;
Student(int id) { this.id = id; }
void setId(int id) { this.id = id; }
}
public class PassTest {
// 기본타입: 값이 복사되어 넘어온다 → 원본 불변
static void changeInt(int n) { n = 999; }
// String: 매개변수(복사된 주소)에 새 값을 대입했을 뿐 → 원본 불변 (replaceStudent와 같은 이유)
static void changeString(String st) { st = "안녕하세요"; }
// 참조타입: 주소가 복사되어 넘어온다 → 필드를 바꾸면 원본도 바뀐다
static void changeStudent(Student st) { st.setId(45); }
// 하지만 매개변수 자체에 새 객체를 대입하면 원본은 그대로다
static void replaceStudent(Student st) { st = new Student(99); }
public static void main(String[] args) {
int n = 10;
changeInt(n);
System.out.println(n); // 10 (그대로)
String s = "hello";
changeString(s);
System.out.println(s); // hello (그대로)
Student stu = new Student(25);
changeStudent(stu);
System.out.println(stu.id); // 45 ← 원본이 바뀌었다!
replaceStudent(stu);
System.out.println(stu.id); // 45 (99가 아니다 — 새 객체를 대입했을 뿐)
}
}
핵심 정리
기본타입 = 변수에 값 자체 저장 / 참조타입 = 객체는 Heap, 변수에는 주소 저장.
자바는 항상 pass by value — 기본타입은 값, 참조타입은 주소 값이 복사되어 전달된다.
참조타입은 복사된 주소로 객체 내부(필드·원소)를 고치면 원본 객체가 바뀐다.
매개변수에 새 값을 대입하는 것은 String이든 Student든 어떤 타입이든 원본에 영향이 없다. String은 내용을 바꾸는 메서드가 없을 뿐이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
메서드에 배열을 넘겨 내용을 바꾸면 원본이 바뀌는 이유는?
주솟값이 복사되어 전달되기 때문입니다. 복사된 주소도 같은 객체를 가리키므로, 그 객체의 내용을 고치면 원본이 바뀝니다. 다만 매개변수 자체에 새 배열을 대입하면 원본은 그대로입니다 — 복사된 주소만 바뀌니까요.
자바는 'call by value'인가 'call by reference'인가?
항상 call by value입니다. 다만 참조타입일 때 복사되는 값이 주솟값이라 결과적으로 원본이 바뀌는 것처럼 보일 뿐입니다. 진짜 call by reference라면 매개변수에 새 객체를 대입했을 때 원본도 바뀌어야 하는데, 자바는 그렇지 않습니다.
💼 실무·코딩테스트에서는면접 단골이자 실무 버그의 근원입니다. 리스트를 메서드에 넘겼는데 호출한 쪽 리스트가 바뀌어 있는 사고가 흔하죠. 그래서 실무에서는 방어적 복사(new ArrayList<>(원본))를 하거나 불변 객체로 만들어 넘깁니다. 단 new ArrayList<>(원본)은 리스트 구조(추가·삭제)는 분리되지만 원소 객체는 공유하는 얕은 복사라, 원소의 필드를 고치면 원본에도 보입니다(17. 얕은 복사 · 깊은 복사).
TIP배열도 참조타입이라 메서드에 넘기면 원본이 바뀝니다 — 17. 얕은 복사와 깊은 복사에서 이 성질 때문에 생기는 대표적인 버그와 해결법을 다룹니다.
10
final · 상수와 Wrapper 클래스
finalstatic finalWrapperBoxingUnBoxing
한 줄 요약final은 붙는 위치에 따라 상속 금지(클래스)·오버라이딩 금지(메서드)·값 변경 금지(필드)를 뜻하고, Wrapper 클래스는 기본타입을 객체로 감싼 참조타입으로 Boxing/UnBoxing이 자동 처리된다.
쉽게 말하면final은 "여기서 끝"이라는 봉인 스티커예요. 클래스에 붙이면 자식을 못 만들고, 메서드에 붙이면 자식이 못 고치고, 변수에 붙이면 값을 못 바꿉니다. Wrapper 클래스는 기본타입에 입히는 옷이라, 옷을 입혀야(객체여야) 들어갈 수 있는 곳(컬렉션 등)에 넣을 수 있게 해 줍니다.
final의 세 가지 위치
클래스에 붙이면 상속 금지 — 자식 클래스를 만들 수 없습니다(final class Star {}). 대표적인 예가 String이라 MyString extends String은 불가능합니다. 메서드에 붙이면 오버라이딩 금지(final void inStar() {}). 멤버필드/변수에 붙이면 값 변경 금지(final int a = 10;) — 이후 a = 7;은 컴파일 에러입니다.
상수는 static final
변하지 않는 값을 모든 객체가 공유하게 하려면 static final을 함께 붙이고 이름은 전부 대문자로 씁니다(public static final int NUMBER = 10000;). static만 붙이면 공유되지만 바뀔 수 있고, final만 붙이면 안 바뀌지만 객체마다 따로 생깁니다. 둘 다 붙여야 진짜 "상수"가 됩니다.
Wrapper 클래스 8종
기본타입에 1:1로 대응하는 참조타입입니다 — byte→Byte, short→Short, int→Integer, long→Long, float→Float, double→Double, boolean→Boolean, char→Character. int와 char만 이름이 다르다는 점을 기억하면 됩니다. Wrapper 객체는 참조타입이므로 주소(reference)로 다루고(equals()·hashCode()는 안에 든 값 기준으로 재정의되어 있음), Integer.parseInt()·Integer.MAX_VALUE 같은 유용한 static 멤버도 제공합니다.
Boxing과 UnBoxing
기본타입을 Wrapper에 넣는 것이 Boxing, 반대로 꺼내는 것이 UnBoxing입니다. Java 5부터는 Integer ik = 4;(오토박싱), int a = ik;(오토언박싱)처럼 자동으로 처리됩니다. 기본타입을 Object에 대입하는 것도 오토박싱 덕분에 가능하며, 꺼낼 때는 (Integer)o로 캐스팅해야 합니다.
JAVAFinalWrapperTest.java
final class Star { // 상속 금지 — extends Star 불가
final void shine() { } // 오버라이딩 금지
}
public class FinalWrapperTest {
public static final double PI = 3.141592; // 상수: static final + 대문자
public static void main(String[] args) {
final int a = 5;
// a = 7; // 에러! final 변수는 값 변경 불가
System.out.println(a + " " + PI);
// ---- Boxing / UnBoxing ----
Integer box = 10; // 오토박싱 : int → Integer
int un = box; // 오토언박싱 : Integer → int
Object o = un; // 기본타입도 Object에 담긴다(오토박싱)
int back = (Integer) o; // 꺼낼 땐 캐스팅
System.out.println(box + " " + un + " " + back);
// ---- Wrapper가 제공하는 유용한 기능들 ----
System.out.println(Integer.MAX_VALUE); // 2147483647
System.out.println(Integer.parseInt("123")); // 문자열 → int
System.out.println(Character.isDigit('9')); // true
System.out.println(Double.parseDouble("3.5"));
// ---- 컬렉션에는 기본타입을 못 넣는다 → Wrapper가 필요한 이유 ----
java.util.List<Integer> list = new java.util.ArrayList<>();
list.add(10); // int 10이 오토박싱되어 Integer로 저장된다
int first = list.get(0); // 꺼낼 때 오토언박싱
System.out.println(first);
}
}
핵심 정리
final: 클래스 → 상속 금지 / 메서드 → 오버라이딩 금지 / 변수 → 값 변경 금지.
상수는 static final + 전부 대문자로 쓴다.
Wrapper 8종 중 이름이 다른 것은 int→Integer, char→Character 두 개뿐.
Boxing(기본→Wrapper)·UnBoxing(Wrapper→기본)은 Java 5부터 자동 처리된다.
컬렉션(List·Map 등)은 객체만 담을 수 있어 Wrapper가 반드시 필요하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
final이 붙은 참조 변수의 객체 내용은 바꿀 수 있을까?
바꿀 수 있습니다.final은 그 변수가 가리키는 주소를 못 바꾼다는 뜻이지 객체 내용을 잠그는 게 아닙니다. final List list에 list.add()는 되지만 list = new ArrayList()는 안 됩니다. 이 구분이 핵심입니다.
Wrapper 클래스가 필요한 이유는?
컬렉션이 객체만 담을 수 있기 때문입니다. List<int>는 안 되고 List<Integer>여야 하죠. 또 null로 "값 없음"을 표현할 수 있고, parseInt 같은 유용한 메서드도 함께 제공됩니다.
💼 실무·코딩테스트에서는Integer 캐싱 함정이 유명합니다 — -128~127은 캐시된 같은 객체라 ==가 true지만, 그 밖은 false가 됩니다. 그래서 Wrapper 비교는 반드시 equals()를 써야 합니다. 실무에서 조용히 틀리는 대표적인 버그이고 면접에서도 자주 나옵니다.
TIP위 캐싱 함정을 피하는 방법은 equals() 외에 a.intValue() == b.intValue()처럼 기본타입으로 꺼내 비교하는 것도 있습니다. 단 Wrapper가 null이면 꺼내는 순간(언박싱) NullPointerException이 나므로 null 가능성을 먼저 확인하세요.
11
싱글턴 패턴(Singleton) — 인스턴스를 하나만
Singletonprivate 생성자getInstance디자인 패턴
한 줄 요약생성자를 private으로 막고 자기 자신 타입의 static 필드를 두어, static 메서드 getInstance()로만 객체를 얻게 하면 Heap에 인스턴스가 단 하나만 존재하게 된다.
쉽게 말하면싱글턴은 회사의 대표 전화번호 같아요. 직원마다 대표번호를 새로 만들 순 없고, 누가 물어봐도 "그 번호"를 알려 줍니다. 생성자를 private으로 막는 건 번호를 새로 만들 수 있는 창구를 잠그는 것이고, getInstance()는 이미 있는 번호를 알려 주는 안내 데스크입니다.
왜 필요한가
설정 정보, 로그 기록기, DB 연결 관리자처럼 프로그램 전체에서 하나만 있으면 되는 객체가 있습니다. 이런 것을 여러 개 만들면 메모리가 낭비되고, 각자 다른 상태를 갖게 되어 버그의 원인이 됩니다. 싱글턴은 Heap 영역에 단 한 개의 인스턴스만 존재하도록 강제하는 설계 패턴입니다.
3단계 구현 방법
① 생성자를 private으로 선언해 외부에서 new를 못 쓰게 막습니다. ② 자신의 클래스 타입과 같은 private static 멤버필드를 선언해 유일한 객체의 주소를 담습니다. ③ 외부에서 접근할 public static 메서드(getInstance())를 만듭니다 — 생성자가 private이라 객체를 만들 수 없으므로, 이 메서드는 반드시 static이어야 객체 없이 호출할 수 있습니다.
검증 방법
두 번 getInstance()를 호출한 뒤 obj1 == obj2(주소 비교)가 true면 같은 인스턴스입니다 — 같은 인스턴스인지는 == 하나로 충분합니다. obj1.equals(obj2)도 true로 나오지만, 이는 equals를 재정의하지 않아 Object의 기본 equals(= 주소 비교)가 쓰였기 때문입니다(재정의했다면 다른 객체여도 true가 될 수 있습니다). 처음 호출할 때만 new가 실행되고 이후에는 이미 만들어진 것을 반환하기 때문입니다(이런 방식을 지연 초기화, lazy initialization이라고 합니다).
실습과제에서 만나는 싱글턴
🧪 9번 마방진 과제의 클래스 다이어그램에 MagicFactory가 -MagicFactory()(private 생성자)와 getInstance()를 가진 싱글턴으로 설계되어 있습니다. 팩토리(객체를 대신 만들어 주는 클래스)는 여러 개일 이유가 없어 싱글턴으로 만드는 것이 전형적인 조합입니다.
JAVASingleton.java + SingletonMain.java
public class Singleton {
// ② 자기 자신 타입의 static 필드 — 유일한 인스턴스의 주소를 담는다
private static Singleton singleton;
// ① 생성자를 private으로 막아 외부에서 new 를 못 쓰게 한다
private Singleton() { }
// ③ 객체 없이 부를 수 있어야 하므로 반드시 static
public static Singleton getInstance() {
if (singleton == null) { // 아직 안 만들어졌을 때만 생성 (지연 초기화)
singleton = new Singleton();
}
return singleton; // 두 번째부터는 기존 것을 그대로 반환
}
}
public class SingletonMain {
public static void main(String[] args) {
// Singleton s = new Singleton(); // 에러! 생성자가 private
Singleton obj1 = Singleton.getInstance();
Singleton obj2 = Singleton.getInstance();
System.out.println((obj1 == obj2)
? "obj1과 obj2는 같은 인스턴스를 가짐" : "다른 인스턴스"); // 주소 비교
System.out.println((obj1.equals(obj2))
? "obj1과 obj2는 같은 인스턴스를 가짐" : "다른 인스턴스"); // 둘 다 true
}
}
핵심 정리
싱글턴 = Heap에 인스턴스를 단 하나만 두는 디자인 패턴.
구현 3요소: private 생성자 + private static 자기 타입 필드 + public static getInstance().
getInstance()가 static이어야 하는 이유는 생성자가 private이라 객체를 만들 수 없기 때문.
같은 인스턴스인지는 ==(주소 비교)로 확인할 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
싱글턴을 쓰는 이유를 구체적인 상황으로 설명해보세요.
여러 개 존재하면 곤란한 것에 씁니다 — DB 커넥션 풀, 설정 정보, 로거처럼요. 커넥션 풀이 여러 개면 연결 수 관리가 무너지고, 설정이 여러 벌이면 어느 것이 진짜인지 알 수 없습니다.
생성자를 private으로 막는 것이 싱글턴에서 왜 필수인가?
외부에서 new로 마음대로 만들지 못하게 해야 하기 때문입니다. 생성자가 열려 있으면 아무리 getInstance()를 제공해도 누군가 new로 두 번째 객체를 만들 수 있어 싱글턴이 깨집니다.
💼 실무·코딩테스트에서는실무에서는 스프링 같은 프레임워크가 빈(Bean)을 기본으로 싱글턴으로 관리하기 때문에 직접 구현할 일이 줄었습니다. 다만 왜 싱글턴이 필요한가, 멀티스레드에서 안전하게 만들려면 어떻게 하나는 여전히 면접 단골입니다.
TIP위 코드는 멀티스레드 환경에서는 안전하지 않습니다(두 스레드가 동시에 null 검사를 통과하면 객체가 두 개 생길 수 있음). 실무에서는 synchronized를 걸거나 static 필드에서 바로 초기화하는 방식을 씁니다 — 🛠️ 스레드 카드의 동기화와 연결됩니다.
12
String의 특징과 String pool 메모리 구조
StringimmutableString poolConcatenation
한 줄 요약String은 참조타입이면서도 기본타입처럼 new 없이 만들 수 있고 immutable하며, 리터럴로 만들면 Heap 안의 String pool에 저장되어 같은 값이면 같은 객체를 재사용한다.
쉽게 말하면String pool은 공용 문구 창고예요. "java"라고 쓰면 창고에 이미 있는 "java" 팻말을 가리키고, 또 "java"를 써도 새로 만들지 않고 같은 팻말을 가리킵니다. 반면 new String("java")는 같은 글자를 새 팻말에 따로 적어 자기 자리에 두는 것이라, 글자는 같아도 팻말(주소)은 다릅니다.
String의 특별한 위치
String은 리터럴 문법이 있는 특별한 불변(immutable) 참조타입이라, 참조타입이면서도 기본타입처럼 쓰이는 면이 있습니다. ① new 없이 String str = "string";으로 객체를 만들 수 있고 ② immutable해서 값을 바꾸는 연산은 항상 새 객체를 만들며 ③ equals()가 내용(문자)을 비교하도록, hashCode()도 내용에 따라 정해지도록 Object의 메서드가 오버라이드되어 있습니다.
immutable이 실제로 뜻하는 것
String str = "ABCD"; String str2 = str + "K";를 실행해도 str은 여전히 "ABCD"입니다. + 연산이 원래 문자열을 고친 게 아니라 "ABCDK"라는 새 String을 만들어 str2에 준 것이기 때문입니다. 그래서 반복문 안에서 문자열을 계속 이어 붙이면 매번 새 객체가 만들어져 성능이 나빠집니다 → 이때 StringBuilder를 씁니다.
Concatenation(문자열 연결)
String + 기본타입은 결과가 String이 됩니다. 계산은 왼쪽부터 순서대로 이루어지므로 1 + 2 + "hello"는 먼저 1+2=3이 계산되어 "3hello"가 되고, "hello" + 1 + 2는 왼쪽부터 붙어 "hello12"가 됩니다.
두 가지 생성 방식의 메모리 차이
리터럴 방식(String str1 = "Java";): Heap 안의 String pool 영역에 생성되며, 같은 값의 문자열을 또 만들면 새로 만들지 않고 pool의 기존 객체를 참조합니다. new 방식(String str2 = new String("java");): pool이 아닌 일반 Heap 영역에 생성되고, pool의 값을 복사해 오지만 새로운 reference를 갖습니다. 값(내용)에 따라 계산되는 hashCode는 둘이 같지만 주소는 다릅니다. 참고로 String pool(Java 7부터 Heap)은 Method Area에 있는 클래스별 상수 풀과는 다른 것입니다(05. 메모리 영역 참고).
JAVAStringPool.java
public class StringPool {
public static void main(String[] args) {
// ---- 리터럴: String pool에 저장되고 같은 값이면 같은 객체를 공유 ----
String obj1 = "java";
String obj2 = "java"; // 새로 만들지 않고 pool의 "java"를 가리킨다
String obj3 = "JavaEdu"; // 값이 다르므로 pool에 새로 만들어진다
System.out.println(obj1 == obj2); // true — 주소가 같다
// ---- new: Heap에 별도 객체 생성 (값은 복사) ----
String str = new String("java");
System.out.println(obj1 == str); // false — 주소가 다르다
System.out.println(obj1.equals(str)); // true — 내용(문자)이 같다
// ---- immutable 확인 ----
String s = "ABCD";
String s2 = s + "K"; // 새 객체가 만들어질 뿐, s는 그대로
System.out.println(s); // ABCD
System.out.println(s2); // ABCDK
// ---- Concatenation: 왼쪽부터 계산된다 ----
int a = 1, b = 2;
System.out.println(a + b + "hello"); // 3hello (숫자 계산 먼저)
System.out.println("hello" + a + b); // hello12 (문자열이 앞이라 계속 연결)
}
}
핵심 정리
String은 참조타입이지만 new 없이 생성 가능하고 immutable하다.
리터럴은 Heap 안의 String pool에 저장되며 같은 값이면 같은 객체를 재사용한다.
new String("java")는 pool 밖 Heap에 새 객체를 만들어 주소가 달라진다.
문자열을 "수정"하는 연산은 전부 새 객체를 만든다 — 반복 연결에는 StringBuilder를 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
String이 불변(immutable)이라 얻는 이점 두 가지는?
① String pool로 같은 문자열을 공유할 수 있습니다 — 바뀔 일이 없으니 안전하게 재사용되죠. ② 여러 스레드가 동시에 써도 안전합니다. 값이 절대 안 변하니 동기화가 필요 없습니다.
new String("abc")와 "abc"의 차이는?
리터럴 "abc"는 String pool에서 공유되지만, new String("abc")는 힙에 별도 객체를 새로 만듭니다. 그래서 두 방식으로 만든 문자열은 내용이 같아도 ==가 false입니다. 문자열 비교에 equals()를 쓰라는 이유가 여기 있습니다.
💼 실무·코딩테스트에서는"String은 왜 불변인가"는 면접 최빈출 중 하나입니다. 보안(파일 경로가 도중에 바뀌면 위험), 해시 캐싱, 스레드 안전성까지 엮어 답하면 좋습니다. 실무에서는 반복문 안 문자열 연결이 성능을 죽이는(매번 새 객체) 문제로 이 성질을 체감하게 됩니다.
TIPString을 final class로 만든 이유도 immutable을 보장하기 위해서입니다(10. final). 여러 곳에서 같은 pool 객체를 공유하는데 누가 값을 바꿀 수 있다면 모두가 영향을 받게 되니까요.
13
String 주요 메서드
indexOfsubstringcharAttrimtoCharArraycompareTo
한 줄 요약문자열은 indexOf(위치 찾기)·charAt(한 글자 꺼내기)·substring(잘라내기)·trim(공백 제거)·toCharArray(문자 배열로 변환)·compareTo(사전 순 비교) 등 검색·추출·변환 메서드를 제공한다.
쉽게 말하면String 메서드는 문장을 다루는 문구용품 세트예요. indexOf는 형광펜으로 위치 찾기, substring은 가위로 오려내기, trim은 여백 잘라내기, toCharArray는 낱글자로 다 흩어놓기입니다. 중요한 건 이 도구들이 원본을 고치지 않고 새 결과를 돌려준다는 점입니다.
찾기: indexOf · charAt · length
indexOf('p')는 그 문자가 처음 등장하는 인덱스를 반환하고 없으면 -1을 줍니다. charAt(n)은 n번 인덱스의 문자 하나(char)를 반환합니다. length()는 문자열 길이인데, 배열의 length는 괄호가 없고 문자열의 length()는 괄호가 있다는 차이를 자주 헷갈립니다.
자르기·다듬기: substring · trim
substring(3)은 3번 인덱스부터 끝까지, substring(3, 5)는 3번부터 5번 앞까지(5번은 포함하지 않음)를 잘라 새 문자열로 반환합니다. trim()은 앞뒤 공백만 제거하며 중간 공백은 남깁니다.
변환: toCharArray · valueOf · 대소문자
toCharArray()는 문자열을 char[]로 바꿔 주며, 한 글자씩 반복 처리할 때 필수입니다. 반대로 new String(charArray)로 되돌릴 수 있습니다. String.valueOf(값)은 어떤 타입이든 문자열로 바꿔 주고, toUpperCase()·toLowerCase()는 대소문자를 변환한 새 문자열을 반환합니다(원본 불변).
비교: equalsIgnoreCase · compareTo
equalsIgnoreCase()는 대소문자를 무시하고 값을 비교합니다. compareTo()는 사전 순으로 비교해 앞서면 음수, 같으면 0, 뒤면 양수를 반환합니다 — "ab".compareTo("bc")는 첫 글자 'a'와 'b'의 코드 차이인 -1을 돌려줍니다. 정렬 기준을 만들 때 자주 쓰입니다.
JAVAStringMethodTest.java
import java.util.Arrays;
public class StringMethodTest {
public static void main(String[] args) {
// ---- 찾기 ----
String indexStr = "Happy Birthday To 한경";
System.out.println(indexStr.indexOf('p')); // 2 (처음 나온 위치)
System.out.println(indexStr.indexOf("To")); // 문자열도 찾을 수 있다
System.out.println(indexStr.indexOf('z')); // -1 (없으면 -1)
String charAtStr = "white";
int n = charAtStr.indexOf('i');
System.out.println(n + ":" + charAtStr.charAt(n)); // 2:i
System.out.println(charAtStr.length()); // 5 (문자열은 length())
// ---- 자르기·다듬기 ----
String subStr = "getitbeauty";
System.out.println(subStr.substring(5)); // beauty (5번부터 끝까지)
System.out.println(subStr.substring(3, 5)); // it (3번 ~ 5번 앞까지)
String trimStr = " 한경닷컴 ";
System.out.println(trimStr.length()); // 앞뒤 공백 포함 길이
System.out.println(trimStr.trim().length()); // 앞뒤 공백만 제거된 길이
// ---- 변환 ----
char[] c = "star Dust".toCharArray();
System.out.println(c.length); // 9 (배열은 length, 괄호 없음)
System.out.println(Arrays.toString(c)); // [s, t, a, r, , D, u, s, t]
System.out.println(new String(c)); // 다시 문자열로
System.out.println("hello".toUpperCase()); // HELLO (원본은 그대로)
System.out.println(String.valueOf(10.13592)); // "10.13592"
// ---- 비교 ----
System.out.println("Eclipse".equalsIgnoreCase("eclipse")); // true
System.out.println("ab".compareTo("bc")); // -1 (사전순으로 앞)
}
}
핵심 정리
모든 String 메서드는 원본을 바꾸지 않고 새 결과를 반환한다(immutable).
indexOf는 못 찾으면 -1, substring(a, b)는 b 인덱스를 포함하지 않는다.
문자열은 length()(괄호 O), 배열은 length(괄호 X).
compareTo는 사전 순 비교 결과를 음수/0/양수로 반환한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
substring(2, 5)가 반환하는 범위를 정확히 말해보세요.
인덱스 2부터 4까지입니다 — 끝 인덱스는 포함하지 않습니다. 길이는 5 - 2 = 3글자죠. SQL의 SUBSTR(문자열, 2, 3)이 2번째부터 3글자인 것과 완전히 달라서, 두 언어를 오가면 자주 틀립니다.
indexOf가 못 찾으면 무엇을 돌려주고, 왜 그 값인가?
-1을 돌려줍니다. 인덱스는 0부터 시작하는 양수라 0은 "첫 글자에서 찾았다"는 유효한 결과이기 때문에, 절대 유효할 수 없는 값으로 -1을 씁니다. if (s.indexOf(x) > 0)이라고 쓰면 첫 글자에서 찾은 경우를 놓치는 버그가 됩니다.
💼 실무·코딩테스트에서는코딩테스트 문자열 문제는 split·substring·charAt·replace만으로 상당수가 풀립니다. 다만 인덱스 경계(포함/미포함)를 정확히 아는 것이 관건이라, 헷갈리면 짧은 예로 직접 확인하고 넘어가는 습관이 시간을 아껴 줍니다.
TIPtoCharArray()는 🧪 개미수열처럼 "앞 글자와 다음 글자를 비교하며 세는" 문제에서 핵심 도구입니다. 문자열을 배열로 바꾸면 인덱스로 앞뒤를 자유롭게 비교할 수 있습니다.
14
String 비교 — == 와 equals의 차이
==equalsreferencehashCode
한 줄 요약==는 주소(reference)를 비교하고 String의 equals()는 내용(문자 하나하나)을 비교하므로, 문자열 내용을 비교할 때는 반드시 equals를 써야 한다.
쉽게 말하면==는 "같은 집에 사는가?"를 묻고 equals는 "내용물이 같은가?"를 묻습니다. 리터럴로 만든 문자열은 같은 집(String pool)에 살아서 둘 다 true가 나오지만, new로 만들면 집이 달라져 ==만 false가 됩니다. 내용이 궁금할 땐 항상 equals라고 외워두면 실수가 없습니다.
세 가지 경우
① 리터럴 vs 리터럴: 같은 pool 객체를 참조하므로 ==도 equals도 둘 다 true. ② new vs new: 각각 다른 주소이므로 ==는 false, 값은 같으므로 equals는 true. ③ 리터럴 vs new: 역시 주소가 달라 ==는 false, equals는 true. 결론은 언제나 "equals가 정확하다"입니다.
String이 equals를 재정의해 둔 이유
Object의 기본 equals()는 ==와 똑같이 주소를 비교합니다(hashCode와는 무관). 그래서 재정의하지 않은 일반 객체는 내용이 같아도 equals가 false입니다. String은 "내용이 같으면 같은 문자열"로 다뤄야 쓸모가 있으므로, equals를 길이와 문자를 한 글자씩 비교하도록 재정의해 두었습니다(규약에 맞춰 hashCode도 내용 기준으로 함께 재정의). hashCode는 해시 자료구조에서 칸을 고르는 보조 값일 뿐 equals가 비교하는 대상이 아닙니다. 내가 만든 클래스도 그렇게 하려면 equals와 hashCode를 직접 오버라이드해야 합니다.
실전에서 터지는 버그
키보드나 파일에서 읽은 문자열은 대부분 new로 만들어진 객체라 pool을 쓰지 않습니다. 그래서 if (input == "yes")는 눈으로는 맞아 보여도 항상 false가 됩니다. 반드시 if (input.equals("yes"))로 써야 하며, input이 null일 수 있다면 if ("yes".equals(input))처럼 리터럴을 앞에 두면 NullPointerException까지 막을 수 있습니다.
JAVAStringCompare.java
public class StringCompare {
public static void main(String[] args) {
// ① 리터럴 vs 리터럴 — pool의 같은 객체를 공유
String str1 = "java";
String str2 = "java";
System.out.println(str1 == str2); // true (같은 pool 객체 → 같은 주소)
System.out.println(str1.equals(str2)); // true (문자 j·a·v·a가 모두 같다)
// ② new vs new — 주소가 각각 다르다
String obj1 = new String("java");
String obj2 = new String("java");
System.out.println(obj1 == obj2); // false (new마다 새 객체 → 주소가 다름)
System.out.println(obj1.equals(obj2)); // true (값은 같다)
// ③ 리터럴 vs new
System.out.println(str1 == obj1); // false
System.out.println(str1.equals(obj1)); // true
// ---- 실전 패턴: 리터럴을 앞에 두면 null 이어도 안전 ----
String input = null;
// System.out.println(input.equals("yes")); // NullPointerException!
System.out.println("yes".equals(input)); // false — 안전하다
}
}
핵심 정리
==는 주소 비교, String의 equals()는 내용(문자) 비교 — 내용 비교는 항상 equals. 재정의 안 한 클래스의 equals는 ==와 같다.
리터럴끼리는 pool을 공유해 ==도 true지만, 이것에 의존하면 안 된다.
new가 하나라도 끼면 ==는 false가 된다.
"리터럴".equals(변수) 순서로 쓰면 NullPointerException을 예방할 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
문자열 비교에 ==를 쓰면 왜 위험한지 설명해보세요.
==는 주솟값을 비교하기 때문입니다. 리터럴끼리는 String pool을 공유해 우연히 true가 나와서 더 위험합니다 — 테스트할 때는 잘 되다가 입력받은 문자열이나 new String()이 섞이는 순간 조용히 false가 됩니다.
equals()를 재정의하지 않은 클래스에서 equals는 무엇을 비교하나?
Object의 기본 구현은 ==와 똑같이 주솟값을 비교합니다. 그래서 직접 만든 클래스는 내용이 같아도 equals가 false입니다. 내용 비교를 원하면 반드시 재정의해야 하고, 이때 hashCode도 함께 맞춰야 합니다.
💼 실무·코딩테스트에서는실무에서 ==로 문자열을 비교하는 것은 코드 리뷰 즉시 지적 대상입니다. 반대로 상수와 비교할 때는 "ADMIN".equals(role)처럼 상수를 앞에 두는 습관이 있는데, role이 null이어도 NPE가 안 나기 때문입니다.
TIP기본타입(int, char 등)은 Stack에 값이 그대로 있으므로 ==로 비교하는 것이 맞습니다. "무조건 equals"가 아니라 "객체(참조타입)면 equals"가 정확한 규칙입니다.
15
StringBuffer · StringBuilder와 문자열 자르기
StringBufferStringBuildersplitStringTokenizer
한 줄 요약String은 immutable이라 이어붙일 때마다 새 객체가 생기므로 반복 연결에는 mutable한 StringBuilder(단일 스레드)나 StringBuffer(동기화 지원)를 쓰고, 문자열을 나눌 때는 split(빈 칸도 유지)이나 StringTokenizer(빈 칸 무시)를 쓴다.
쉽게 말하면String으로 문자열을 이어 붙이는 건 종이에 한 글자 추가할 때마다 새 종이에 전부 옮겨 적는 것이고, StringBuilder는 지우개 달린 노트에 계속 덧붙이는 것이에요. split과 StringTokenizer는 가위로 자르는 두 가지 방식인데, split은 빈 조각도 그대로 두고 StringTokenizer는 빈 조각을 버립니다.
String vs StringBuilder vs StringBuffer
저장 영역: 세 가지 모두 객체는 Heap에 만들어집니다. 다만 String 중 리터럴("abc")만 String Pool에 모아 공유하며(Java 7부터 이 pool도 Heap 안에 있음), new String()이나 연산·입력으로 만들어진 String은 pool 밖의 일반 Heap 객체입니다. 변경 가능성: String은 immutable, 나머지 둘은 mutable. 안정성: StringBuilder는 동기화를 지원하지 않아 멀티스레드에 취약하지만 빠르고, StringBuffer는 동기화를 지원해 스레드에 안전하지만 느립니다. 일반적인 단일 스레드 코드에서는 StringBuilder를 쓰면 됩니다.
mutable의 의미
immutable은 "바꾼 결과를 다시 대입하지 않으면 원래 문자열 그대로"이고, mutable은 "다시 대입하지 않아도 원래 것이 바뀐다"는 뜻입니다. 그래서 sb.append("K")만 해도 sb의 내용이 바뀌며, StringBuffer의 hashCode는 내용이 바뀌어도 그대로 유지됩니다(같은 객체이므로). String 타입이 필요한 곳(String 변수에 대입, String을 받는 메서드에 전달)에서는 toString()으로 바꿔서 씁니다. 단 System.out.println(sb)처럼 출력만 할 때는 println이 알아서 toString()을 불러 주므로 생략해도 됩니다.
split — 빈 칸도 유지
String.split(구분자)는 구분자로 잘라 배열로 반환합니다. 구분자가 연속으로 나오면 빈 문자열도 배열 요소로 들어갑니다 — "100,200,300,,,400".split(",")은 길이 6짜리 배열이 되고 중간에 빈 값 두 개가 들어 있습니다. 단 맨 끝에 붙은 빈 문자열은 버려집니다 — "a,b,,".split(",")은 길이 2([a, b])이고, 끝의 빈 값까지 살리려면 split(",", -1)(길이 4)을 씁니다. 정규식을 구분자로 쓸 수 있다는 것도 특징입니다.
StringTokenizer — 빈 칸 무시
java.util.StringTokenizer는 일정한 토큰으로 잘라 순차적으로 꺼내 쓰며, 빈 토큰은 아예 만들지 않습니다. hasMoreTokens()로 남았는지 확인하고 nextToken()으로 하나씩 꺼냅니다(hasMoreElements()·nextElement()도 같은 동작이지만 Enumeration용 짝이라, 짝을 맞춰 쓰는 것이 읽기 좋습니다). "빈 값을 무시하고 싶다면 StringTokenizer, 빈 값도 자리로 인식해야 한다면 split"이 선택 기준입니다.
JAVAStringCutter.java
import java.util.StringTokenizer;
public class StringCutter {
public static void main(String[] args) {
// ---- String으로 반복 연결하면 매번 새 객체가 만들어진다(느림) ----
String s = "";
for (int i = 0; i < 5; i++) s += i; // 새 String이 5번 생성됨
System.out.println(s); // 01234
// ---- StringBuilder: 같은 객체에 덧붙인다(빠름) ----
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) sb.append(i);
sb.insert(0, "[").append("]"); // 앞뒤로 삽입·추가
System.out.println(sb.toString()); // [01234]
System.out.println(sb.reverse()); // ]43210[ (뒤집기, println이 toString을 자동 호출)
// ---- split: 빈 값도 배열 요소로 남는다 ----
String[] arr = "100,200,300,,,400".split(",");
System.out.println(arr.length); // 6
for (String a : arr) System.out.print("[" + a + "]"); // [100][200][300][][][400]
System.out.println();
// ---- StringTokenizer: 빈 값은 아예 만들지 않는다 ----
StringTokenizer st = new StringTokenizer("100,200,300,,,400", ",");
System.out.println(st.countTokens()); // 4
while (st.hasMoreTokens()) { // nextToken()의 짝은 hasMoreTokens()
System.out.print("[" + st.nextToken() + "]"); // [100][200][300][400]
}
}
}
핵심 정리
String(immutable, 리터럴만 pool 공유) / StringBuilder(mutable, 빠름, 비동기) / StringBuffer(mutable, 동기화, 느림) — 객체는 모두 Heap.
반복문 안에서 문자열을 이어 붙일 때는 StringBuilder를 쓴다.
split은 중간의 빈 문자열은 배열 요소로 남기되 끝의 빈 문자열은 버린다(살리려면 split(",", -1)). StringTokenizer는 빈 토큰을 무시한다.
StringBuilder/Buffer를 String으로 쓸 때는 toString()으로 바꾼다(println은 toString을 자동 호출하므로 출력만 할 땐 생략 가능).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
반복문에서 String + 연결이 느린 이유를 객체 생성으로 설명해보세요.
String은 불변이라 연결할 때마다 새 객체를 만들고 이전 것은 버려집니다. 1만 번 반복하면 1만 개의 임시 객체가 생겼다 사라지죠. StringBuilder는 내부 버퍼에 이어 붙이므로 객체를 새로 만들지 않습니다.
StringBuffer와 StringBuilder 중 무엇을 언제 쓰나?
StringBuffer는 동기화되어 스레드에 안전하지만 그만큼 느리고, StringBuilder는 동기화가 없어 빠릅니다. 대부분의 문자열 조립은 한 스레드 안에서 일어나므로 StringBuilder가 기본이고, 여러 스레드가 공유할 때만 StringBuffer를 씁니다.
💼 실무·코딩테스트에서는코딩테스트에서 출력이나 문자열 누적이 많으면 StringBuilder가 필수입니다 — String 연결로 짜면 시간 초과가 납니다. 실무에서도 루프 안의 문자열 연결은 정적 분석 도구가 경고하는 대표 패턴입니다.
TIPsplit(".")은 정규식에서 점(.)이 "아무 문자"를 뜻하기 때문에 원하는 대로 동작하지 않습니다. 점으로 자르려면 split("\\.")처럼 이스케이프해야 합니다 — 실무에서 자주 겪는 함정입니다.
16
배열 — 선언 3가지 방법과 2차원 ↔ 1차원 변환
Arraynew int[]2차원 배열가변 배열
한 줄 요약배열은 참조타입이라 Heap에 크기만큼 자리를 잡고 타입별 기본값으로 자동 초기화되며, 2차원 배열의 (i, j)는 1차원에서 i*열개수 + j, 1차원의 i는 2차원에서 [i/열개수][i%열개수]로 서로 변환된다.
쉽게 말하면배열은 번호가 붙은 사물함 한 줄이에요. 처음 만들 때 칸 수를 정하고, 그 순간 모든 칸에 기본값(숫자는 0, 참조는 null)이 들어갑니다. 2차원 배열은 사물함이 여러 줄로 쌓인 것이고, "3층 2번 칸"을 "전체에서 몇 번째 칸인지"로 바꾸는 계산이 i*열개수 + j입니다.
배열의 성질과 선언 3가지
배열은 참조타입(mutable)이며, 선언과 동시에 {1,2,3}처럼 값을 넣는 초기화 문법에서는 new를 쓰지 않아도 배열 객체가 만들어집니다(그 외에는 new가 필요합니다). 선언 방법은 ① int[] a = {1,2,3};(값과 함께 바로) ② int[] b = new int[]{1,2,3};(new + 값) ③ int[] c = new int[3];(크기만 정하고 나중에 채우기). 기본타입 배열은 Heap에 크기만큼 자리가 확보되면서 타입별 기본값(int 0, double 0.0, boolean false, char \u0000)으로 초기화되고, 참조타입 배열은 null로 초기화됩니다.
2차원 배열 선언
int[][] a2 = new int[2][3];(2행 3열), int[][] a3 = {{1,2,3},{4,5,6}};(값과 함께), 그리고 가변(ragged) 배열int[][] a4 = new int[2][]; 후 a4[0] = new int[3]; a4[1] = new int[4];처럼 행마다 열 개수를 다르게 만들 수도 있습니다. 접근은 a2[행][열]입니다.
2차원 → 1차원, 1차원 → 2차원 공식
2차원 (i행, j열) → 1차원 인덱스: i * col + j (col = 열 개수). 3열짜리라면 (0,0)→0, (0,2)→2, (1,0)→3이 됩니다. 1차원 인덱스 i → 2차원: [i / col][i % col]. 나눗셈 몫이 행, 나머지가 열입니다. 이 공식은 🧪 마방진에서 1~16을 순서대로 2차원에 채울 때 그대로 쓰입니다.
JAVAArrayTest.java
import java.util.Arrays;
public class ArrayTest {
public static void main(String[] args) {
// ---- 선언 3가지 ----
int[] a = {1, 2, 3};
int[] b = new int[]{1, 2, 3};
int[] c = new int[3]; // 0, 0, 0 으로 자동 초기화
String[] s = new String[2]; // null, null (참조타입은 null)
System.out.println(Arrays.toString(c) + Arrays.toString(s));
// ---- 2차원 배열 ----
int[][] a2 = new int[2][3];
a2[0][0] = 1;
a2[1][2] = 6;
int[][] a3 = {{1,2,3}, {4,5,6}};
System.out.println(Arrays.deepToString(a3)); // [[1, 2, 3], [4, 5, 6]]
// 가변(ragged) 배열 — 행마다 열 개수가 다를 수 있다
int[][] a4 = new int[2][];
a4[0] = new int[3];
a4[1] = new int[4];
System.out.println(a4[0].length + " " + a4[1].length); // 3 4
// ---- 1차원 → 2차원 변환: [i/col][i%col] ----
int col = 4;
int[][] magic = new int[4][4];
for (int i = 0; i < 16; i++) {
magic[i / col][i % col] = i + 1; // 1~16을 순서대로 채운다
}
System.out.println(Arrays.deepToString(magic));
// ---- 2차원 → 1차원 변환: i*col + j ----
int[] flat = new int[16];
for (int i = 0; i < 4; i++)
for (int j = 0; j < col; j++)
flat[i * col + j] = magic[i][j];
System.out.println(Arrays.toString(flat));
}
}
핵심 정리
배열은 참조타입이며 생성 시 타입별 기본값(참조타입은 null)으로 자동 초기화된다.
선언: {값} / new 타입[]{값} / new 타입[크기] 세 가지.
2차원 (i,j) → 1차원: i*col + j / 1차원 i → 2차원: [i/col][i%col].
배열 길이는 length(괄호 없음), 한 번 정한 크기는 바꿀 수 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
배열의 length는 왜 메서드가 아니라 필드인가?
배열은 소스 코드로 정의된 클래스가 아니라 JVM이 특별 취급하는 타입입니다(실행 중에는 int[] 같은 배열용 클래스가 자동으로 있어 arr.getClass()도 동작합니다). 자바 언어 명세가 배열의 길이를 length라는 final 필드로 정해 두었기 때문에 arr.length(필드)이고, String은 클래스라 s.length()(메서드), 컬렉션은 list.size()입니다 — 셋이 다 달라서 자주 헷갈립니다.
2차원 배열이 실제 메모리에서 어떤 모양인지 설명해보세요.
배열의 배열입니다. int[3][4]는 길이 3짜리 참조 배열이 있고, 각 칸이 길이 4짜리 int 배열을 가리키는 구조죠. 그래서 arr[i].length가 행마다 다를 수 있는 가변 배열도 가능합니다.
💼 실무·코딩테스트에서는코딩테스트에서 2차원 배열은 격자·미로·행렬 문제의 기본입니다. arr.length가 행 수, arr[0].length가 열 수라는 것을 헷갈리면 통째로 틀리므로, 문제를 풀기 전에 한 번 출력해 확인하는 습관이 안전합니다.
TIP배열은 크기가 고정이라 중간에 늘릴 수 없습니다. 개수가 유동적이라면 ArrayList를 쓰세요 — 🧪 로또 문제에서 Lotto는 배열(항상 6개), LottoStore는 매수가 달라지므로 배열이나 ArrayList를 쓰는 식으로 나뉩니다.
17
향상된 for문과 얕은 복사 · 깊은 복사
Enhanced forShallow CopyDeep Copyarraycopyclone
한 줄 요약향상된 for문은 인덱스 없이 요소를 순회한다. 배열 복사는 세 단계로 구분한다 — ① 참조 복사(dest = src, 같은 배열 공유) ② 얕은 복사(clone()·arraycopy, 새 배열이지만 칸 안의 객체는 공유) ③ 깊은 복사(안쪽 객체까지 새로 만듦). 1차원 기본형 배열은 ②만으로도 완전히 독립된다.
쉽게 말하면참조 복사는 같은 문서의 링크를 공유하는 것, 얕은 복사는 목차(칸)는 새로 인쇄했지만 각 항목이 여전히 같은 링크를 가리키는 것, 깊은 복사는 링크 너머 내용까지 통째로 인쇄해 주는 것이에요. 링크를 받은 사람이 내용을 고치면 원본도 바뀌지만, 통째로 인쇄한 사본에 낙서해도 원본은 멀쩡합니다.
향상된 for문(Enhanced for)
for (타입 변수 : 배열또는컬렉션) { } 형태로, 인덱스를 쓰지 않고 요소를 하나씩 꺼내 순회합니다. 코드가 짧고 인덱스 실수(off-by-one)가 없다는 장점이 있지만, 인덱스를 알 수 없고 요소를 대입해 바꿀 수 없다는 한계가 있습니다. 값을 수정해야 하거나 인덱스가 필요하면 일반 for문을 씁니다.
① 참조 복사 — dest = src
int[] dest = src;는 배열을 복사한 게 아니라 주소값만 대입한 것입니다(새 배열이 생기지 않음). 그래서 dest[0] = 99;를 하면 src[0]도 99가 됩니다. 두 변수가 같은 배열 객체를 가리키고 있기 때문입니다. 수업에서는 이것을 "얕은 복사"라고 부르기도 했지만, 정확히는 복사가 아니라 참조(주소) 공유입니다.
② 얕은 복사(Shallow Copy) — clone() · arraycopy · Arrays.copyOf
새 배열을 만들고 각 칸의 값을 그대로 옮깁니다. 배열 자체는 별개라 deep[0] = 99;처럼 칸을 바꿔도 원본은 그대로입니다. 방법은 System.arraycopy(원본, 시작인덱스, 대상, 시작인덱스, 길이), Arrays.copyOf(), 배열의 clone()이며 기본형·객체 배열 어디에나 쓸 수 있습니다. 칸에 든 것이 기본형 값이면(1차원 int[] 등) 이것만으로 완전히 독립된 복사본 = 깊은 복사와 같은 결과가 됩니다.
③ 깊은 복사(Deep Copy) — 객체 배열·2차원 배열은 따로 해야 한다
객체 배열이나 2차원 배열은 각 칸에 들어 있는 것이 "객체(또는 안쪽 배열)의 주소"라서, ②의 방법으로는 두 배열이 같은 객체들을 계속 공유합니다(그래서 얕은 복사). 안쪽 객체까지 새로 만들어 완전히 독립시키는 것이 깊은 복사이며, 하려면 ① 각 객체마다 clone() 구현(implements Cloneable) ② 새 객체를 만들어 필드를 하나씩 복사 ③ 직렬화 후 역직렬화(성능 저하) 중 하나를 써야 합니다.
JAVACopyTest.java
import java.util.Arrays;
public class CopyTest {
public static void main(String[] args) {
// ---- 향상된 for문 ----
String[] str = {"여자", "남자", "사람"};
for (int i = 0; i < str.length; i++) System.out.print("[" + str[i] + "]"); // 일반 for
System.out.println();
for (String s : str) System.out.print("[" + s + "]"); // 향상된 for
System.out.println();
// ---- ① 참조 복사: 주소만 복사되어 원본까지 바뀐다 ----
int[] src = {1, 5, 9};
int[] dest = src; // 같은 배열을 가리킨다
dest[0] = 99;
System.out.println(Arrays.toString(src)); // [99, 5, 9] ← 원본도 바뀜!
// ---- ② 얕은 복사: 새 배열에 칸의 값을 옮긴다 (1차원 int[]라 결과는 깊은 복사와 같다) ----
int[] src2 = {1, 5, 9};
int[] deep = new int[src2.length];
System.arraycopy(src2, 0, deep, 0, src2.length);
deep[0] = 99;
System.out.println(Arrays.toString(src2)); // [1, 5, 9] ← 원본은 그대로
System.out.println(Arrays.toString(deep)); // [99, 5, 9]
// 같은 효과의 간단한 방법들
int[] copy1 = Arrays.copyOf(src2, src2.length);
int[] copy2 = src2.clone();
System.out.println(Arrays.toString(copy1) + Arrays.toString(copy2));
}
}
핵심 정리
향상된 for는 인덱스 없이 순회 — 짧지만 인덱스를 못 쓰고 요소를 대입할 수 없다.
① 참조 복사: 배열 대입(dest = src)은 주소만 복사 — 같은 배열이라 한쪽을 바꾸면 둘 다 바뀐다.
② 얕은 복사: System.arraycopy / Arrays.copyOf / clone() — 새 배열이지만 칸 안의 객체(안쪽 배열)는 공유한다. 1차원 기본형 배열이면 이것만으로 완전히 독립된다.
③ 깊은 복사: 객체 배열·2차원 배열은 안쪽 객체·배열까지 하나씩 새로 만들어야 진짜 독립된 복사본이 된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
향상된 for문으로 배열 값을 바꿀 수 없는 이유는?
루프 변수가 원본 칸이 아니라 값을 복사받은 별도 변수이기 때문입니다. for (int x : arr) x = 0;은 복사본만 바꿀 뿐이죠. 원본을 바꾸려면 인덱스 for문을 써야 합니다.
객체 배열에서 clone()이 완전한 복사가 아닌 이유는?
clone()은 각 칸의 주솟값만 복사하기 때문입니다(얕은 복사). 복사본의 [0]과 원본의 [0]이 같은 객체를 가리키므로, 그 객체의 내용을 바꾸면 양쪽 다 바뀝니다. 진짜 깊은 복사를 하려면 각 원소를 하나씩 새로 만들어야 합니다.
💼 실무·코딩테스트에서는얕은 복사 vs 깊은 복사는 면접 단골이자 실무 버그의 단골입니다. 리스트를 복사해 넘겼는데 원본이 같이 바뀌는 사고가 흔하죠. new ArrayList<>(원본)도 얕은 복사라는 걸 아는 것이 중요합니다 — 리스트 구조는 분리되어 복사본에 add·remove를 해도 원본은 그대로지만, 원소 객체는 공유되므로 원소의 필드를 고치면 양쪽에 다 보입니다. 그래서 방어적 복사(09. 값 전달과 주소 전달)로 막을 수 있는 것은 "목록 자체의 변경"까지입니다.
TIP"정렬 전 원본을 남겨두고 싶다"는 상황이 얕은 복사 버그의 단골입니다. int[] backup = arr; 후 Arrays.sort(arr)를 하면 backup도 정렬되어 버립니다 — arr.clone()을 쓰세요.
18
OOP 3대 개념과 상속 · 메모리 4대 특징
EncapsulationInheritancePolymorphismextends자생부생
한 줄 요약객체지향의 3대 개념은 은닉화(데이터 보호)·상속성(코드 재사용)·다형성(하나의 타입으로 여러 형태 처리)이며, 자식 객체를 만들면 그 안의 부모 부분이 부모 생성자로 먼저 초기화되고(자생부생), 부모 타입 변수로 참조하면 컴파일러는 부모에 선언된 메서드만 호출을 허락한다(설공메사).
쉽게 말하면은닉화는 자판기(내부는 안 보이고 버튼으로만 조작), 상속은 가업 물려받기(부모의 기술을 그대로 쓰면서 내 것도 추가), 다형성은 "동물"이라고 부르면 개는 멍멍, 고양이는 야옹 하는 것이에요. 그리고 자식 객체를 만들 때는 그 안의 부모 부분부터 먼저 초기화된다(자생부생)는 규칙이 메모리에서도 그대로 적용됩니다.
① 은닉화(Encapsulation)
데이터 보호를 위해 멤버필드를 private으로 막고, 메서드를 통해서만 접근하게 하는 것입니다. 클래스 B가 클래스 A의 private 필드에 직접 접근하는 길을 막고 public 메서드라는 창구만 열어 둡니다. 패키지나 jar(아카이브)로 묶는 것도 더 큰 범위의 은닉화입니다. 엄밀히 나누면 캡슐화는 데이터와 그 데이터를 다루는 메서드를 한 클래스로 묶는 것이고, 정보 은닉은 그중 내부를 private으로 감추는 것입니다(이 카드에서는 둘을 묶어 은닉화라고 부릅니다). 또 이 카드는 3대 개념으로 나눠 다루지만, 보통은 추상화(공통 특징만 뽑아 타입으로 정의하기 — 21. 추상 클래스·22. 인터페이스)를 더해 4대 특징으로 부릅니다.
② 상속성(Inheritance)
하위 클래스가 상위 클래스에서 정의한 속성과 메서드를 그대로 사용할 수 있게 하는 것으로 extends 키워드를 씁니다(Generalization, 일반화라고도 부릅니다). 부모를 가리킬 땐 super, 자신은 this를 씁니다. 단 오버라이딩되지 않는 것이 넷 있습니다 — ① static 메서드(자식에 같은 모양으로 만들면 재정의가 아니라 부모 것을 가리는(hiding) 것이라, 변수 타입 기준으로 호출됨) ② 부모의 생성자(아예 물려받지 못함) ③ 부모의 private 메서드(자식에게 보이지 않음) ④ final 메서드(재정의 금지라 시도하면 컴파일 에러).
③ 다형성(Polymorphism)
다양한 형태를 나타낼 수 있는 능력으로, 전제 조건은 상속 + 오버라이딩입니다. 발생 원리 3가지를 수업에서는 줄임말로 외웁니다(각 줄임말 옆이 풀어 쓴 뜻입니다).
부타자생(부모 타입 · 자식 생성) — 부모 타입 변수에 자식 객체를 새로 만들어 담을 수 있다: Parent p = new Child();
부타자참(부모 타입 · 자식 참조) — 이미 있는 자식 객체를 부모 타입 변수로도 가리킬 수 있다: Parent p2 = child;(다시 자식 기능을 쓰려면 (Child)p2로 캐스팅)
부메자호(부모 메서드 · 자식 호출) — 부모 타입 변수로 메서드를 불러도, 자식이 오버라이딩했다면 실제로는 자식의 메서드가 실행된다(= VMI, Virtual Method Invocation).
메모리 4대 특징 (줄임말로 외우기)
수업 줄임말과 풀어 쓴 뜻을 나란히 적습니다.
자생부생(자식을 생성하려면 · 부모가 먼저 생성) — 글자 순서와 달리 부모 부분이 먼저입니다. new Child() → 자식 생성자 첫 줄의 super() → 부모 생성자 본문 실행 → 자식 생성자 나머지 실행 순서라, 객체 안의 부모 부분이 먼저 초기화된다(heap). 부모 객체가 따로 하나 더 생기는 것은 아니며, 부모 생성자를 호출할 수 없으면(예: 부모에 기본 생성자가 없는데 super(...)를 안 씀) 컴파일 에러가 납니다.
자설부설(자식 설계도 · 부모 설계도) — 자식 클래스 정보가 로딩될 때 부모 클래스 정보도 함께(먼저) 로딩된다(Method Area).
생주부주(생성 주소 · 부모 주소) — 메모리에는 부모 필드와 자식 필드를 모두 가진 객체가 하나 만들어지고, 부모 타입 변수는 그 같은 객체의 주소를 담는다. 달라지는 것은 변수의 타입뿐이라 "부모 쪽 모습으로만 보인다"는 뜻입니다.
설공메사(설계도 공개 메서드만 사용) — 변수 타입의 설계도에 선언된 메서드만 호출할 수 있다. 부모 타입으로 참조하면 부모에 선언된 메서드만 호출할 수 있고, 자식에만 있는 메서드는 캐스팅해야 쓸 수 있습니다.
JAVAOopBasic.java
// ---- ① 은닉화: 필드는 private, 접근은 메서드로 ----
class Account {
private int balance; // 외부에서 직접 못 건드린다
public void deposit(int money) {
if (money <= 0) return; // 검증할 수 있는 것이 은닉화의 이득
balance += money;
}
public int getBalance() { return balance; }
}
// ---- ② 상속 ----
class Parent {
String name = "부모";
public void print() { System.out.println("부모에서 출력"); }
public void onlyParent() { System.out.println("부모만 가진 기능"); }
}
class Child extends Parent {
@Override
public void print() { System.out.println("자식에서 override되어 실행된다"); }
public void onlyChild() { System.out.println("자식만 가진 기능"); }
}
public class OopBasic {
public static void main(String[] args) {
// ---- ③ 다형성 ----
Parent p = new Child(); // 부타자생: 부모 타입으로 자식을 생성
p.print(); // 부메자호(VMI): 자식의 print()가 실행된다
p.onlyParent(); // 부모에 선언되어 있으므로 호출 가능
// p.onlyChild(); // 에러! 설공메사 — 부모 설계도에 없는 메서드는 못 쓴다
((Child) p).onlyChild(); // 캐스팅하면 사용 가능
Child c = new Child();
Parent p2 = c; // 부타자참: 부모 이름으로 자식을 받는다
Child c2 = (Child) p2; // 다시 자식으로 되돌리려면 명시적 캐스팅
c2.onlyChild();
}
}
핵심 정리
OOP 3대 개념: 은닉화(private + 메서드) · 상속성(extends) · 다형성(상속 + 오버라이딩). 추상화를 더해 4대 특징이라고도 한다.
오버라이딩 안 되는 것: static 메서드(재정의가 아니라 hiding), 부모 생성자, 부모의 private 메서드, final 메서드.
다형성 3원리: 부타자생(부모 타입 변수에 자식 객체 생성) · 부타자참(자식 객체를 부모 타입으로 참조) · 부메자호(부모 타입으로 불러도 오버라이딩된 자식 메서드 실행 = VMI).
메모리 4대 특징: 자생부생(부모 부분이 먼저 초기화) · 자설부설(부모 클래스 정보도 함께 로딩) · 생주부주(객체는 하나, 변수 타입만 부모) · 설공메사(변수 타입에 선언된 메서드만 호출 가능).
부모 타입으로 참조하면 부모에 선언된 멤버만 쓸 수 있고, 자식 고유 기능은 캐스팅이 필요하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
상속을 쓰기 전에 '이게 정말 상속 관계인가'를 어떻게 판단하나?
"자식 is-a 부모"가 자연스러운지 보면 됩니다. "학생 is-a 사람"은 자연스럽지만 "자동차 is-a 엔진"은 어색하죠 — 이건 포함(has-a) 관계라 상속이 아니라 필드로 가지는 것이 맞습니다. 이 판단을 안 하면 상속 계층이 금방 엉킵니다.
상속이 캡슐화를 약하게 만든다는 말은 무슨 뜻인가?
자식이 부모의 내부 구현에 의존하게 되기 때문입니다. 부모가 내부 동작을 바꾸면 자식이 조용히 깨질 수 있습니다. 그래서 현대 설계에서는 "상속보다 조합(composition)"을 권합니다.
💼 실무·코딩테스트에서는OOP 3대 개념은 면접에서 반드시 나옵니다. 정의만 외우지 말고 "그래서 무엇이 좋아지는가"를 말할 수 있어야 합니다 — 캡슐화는 변경 범위를 좁히고, 상속은 중복을 줄이고, 다형성은 새 타입이 추가돼도 기존 코드를 안 고치게 해 줍니다.
TIP자바는 다중 상속을 지원하지 않습니다(클래스는 하나만 extends 가능). 여러 특성을 조합해야 한다면 인터페이스를 여러 개 implements하는 방식으로 해결합니다.
19
오버라이딩(Override)과 VMI
@Override재정의VMIMethod Area
한 줄 요약오버라이딩은 부모가 이미 가진 메서드를 자식이 같은 형태로 다시 정의하는 것이며, 부모 타입으로 호출해도 실행 시점에 실제 객체(자식)의 메서드가 불리는 것이 VMI(Virtual Method Invocation)다.
쉽게 말하면오버라이딩은 물려받은 가게 메뉴를 내 방식으로 바꾸는 것이에요. 간판(메서드 이름·매개변수)은 그대로 두고 레시피만 바꿉니다. 손님이 "부모 가게 간판"을 보고 주문해도, 실제로 요리하는 사람이 자식이면 자식의 레시피가 나옵니다 — 이게 VMI입니다.
오버라이딩의 조건
① 상속 관계가 전제되어야 합니다. ② 오버라이드할 메서드를 부모가 반드시 가지고 있어야 합니다. ③ 부모와 같은 형태(이름·매개변수·리턴타입)여야 합니다. 단 리턴타입이 참조타입이면 부모 리턴타입의 자식 타입으로 좁혀 반환하는 것은 허용됩니다(공변 반환, 예: 부모가 Animal을 반환하면 자식은 Dog 반환 가능). ④ 접근 제한자는 부모보다 좁아질 수 없습니다(public을 private으로 바꾸는 것은 불가). ⑤ checked 예외는 부모 메서드가 선언한 것보다 넓히거나 새로 추가할 수 없습니다(같거나 더 좁은 예외, 또는 생략은 가능). @Override 어노테이션은 필수는 아니지만, 오타로 인해 오버라이딩이 아니라 새 메서드를 만들어 버리는 실수를 컴파일러가 잡아 주므로 항상 붙이는 것이 좋습니다.
왜 재정의하는가
하위 클래스에서 메서드의 역할을 변경하거나 확장할 필요가 있을 때 씁니다. 공통적인 요소는 상위로 끌어올리고, 하위마다 내용이 달라지는 부분만 재정의하는 것이 객체지향 설계의 기본 흐름입니다. 🧪 마방진 과제에서 MagicSquare의 make()를 홀수/짝수/6마방진이 각각 다르게 재정의하는 구조가 정확히 이 형태입니다.
VMI(Virtual Method Invocation)의 메모리 동작
아래 코드 기준으로 보면, Method Area에는 부모 클래스 Shape의 draw()와 자식 클래스 Circle의 draw()가 둘 다 올라가 있습니다. Shape s = new Circle();로 만든 객체의 s.draw()를 호출하면, 컴파일 시점에는 "Shape에 draw()가 있는가"만 확인하고 실행 시점에 실제 객체가 Circle이므로 Circle의 draw()로 연결됩니다. 이 "실행 시점에 결정되는 연결"이 VMI이며, 다형성이 실제로 동작하는 원리입니다.
Method Area가 담는 것
Method Area는 흔히 "static 메모리"라고도 불리지만 더 넓은 개념으로, 클래스의 정보, static 필드·메서드, 상수 풀을 저장합니다. 다만 "설계도에 공개된 메서드만 사용 가능"(설공메사)은 Method Area 때문에 생기는 규칙이 아니라 컴파일러의 검사입니다 — 컴파일할 때는 변수 타입(Shape)의 설계도에 그 메서드가 선언돼 있는지만 보고 호출을 허락하며, 실행할 때 어떤 코드가 돌지는 실제 객체 타입(Circle)이 정합니다(VMI).
JAVAOverrideTest.java
class Shape {
public void draw() { System.out.println("도형을 그린다"); }
public void info() { System.out.println("이것은 도형입니다"); }
}
class Circle extends Shape {
@Override // 오타를 컴파일러가 잡아 준다
public void draw() { System.out.println("○ 원을 그린다"); }
}
class Square extends Shape {
@Override
public void draw() { System.out.println("□ 사각형을 그린다"); }
@Override
public void info() {
super.info(); // 부모 기능을 먼저 실행하고
System.out.println("그중에서도 사각형입니다"); // 내 기능을 덧붙인다(확장)
}
}
public class OverrideTest {
public static void main(String[] args) {
// 부모 타입 배열에 자식 객체들을 담는다 — 다형성의 대표 활용
Shape[] shapes = { new Circle(), new Square(), new Shape() };
for (Shape s : shapes) {
s.draw(); // VMI: 실행 시점에 실제 객체의 draw()가 호출된다
}
// ○ 원을 그린다 / □ 사각형을 그린다 / 도형을 그린다
shapes[1].info(); // 부모 info() 실행 후 자식이 덧붙인 내용까지
}
}
핵심 정리
오버라이딩 = 상속 관계에서 부모가 이미 가진 메서드를 같은 형태로 재정의하는 것.
접근 제한자를 부모보다 좁게 만들 수 없고, @Override는 실수를 막아 준다.
VMI = 부모 타입으로 호출해도 실행 시점의 실제 객체(자식) 메서드가 불리는 것.
부모 기능을 유지하면서 확장하려면 super.메서드()를 먼저 호출한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
오버라이딩한 메서드가 부모 타입 변수로 호출해도 자식 것이 불리는 이유는?
실행 시점에 실제 객체의 타입을 보고 결정하기 때문입니다(동적 바인딩·VMI). 변수의 타입은 컴파일 시점에 무엇을 부를 수 있는지만 정하고, 실제로 무엇이 불릴지는 객체가 정합니다. 이것이 다형성의 실체입니다.
@Override를 붙이면 무엇이 좋아지나?
오타나 시그니처 실수를 컴파일 때 잡아 줍니다. 이름을 잘못 쓰면 오버라이딩이 아니라 새 메서드를 만든 게 되어 조용히 부모 것이 실행됩니다. @Override가 있으면 그 순간 컴파일 에러로 알려 줍니다.
💼 실무·코딩테스트에서는다형성은 실무 설계의 핵심 도구입니다. 결제 수단이 늘어날 때 if (카드) ... else if (계좌) ...를 계속 늘리는 대신, PaymentMethod 인터페이스를 구현한 클래스를 추가하면 기존 코드를 안 건드려도 됩니다. 이것이 개방-폐쇄 원칙(OCP)입니다.
TIP오버라이딩이 안 먹는 것 같으면 매개변수가 미묘하게 다른지 확인하세요. equals(Student s)는 equals(Object o)의 오버라이딩이 아니라 오버로딩이라 전혀 다른 메서드가 됩니다 — @Override를 붙였다면 이 실수를 컴파일 에러로 잡을 수 있습니다.
20
this와 super, 참조타입 형 변환
thissuperthis()super()캐스팅
한 줄 요약this는 자기 자신(필드·메서드·생성자), super는 부모를 가리키며, 괄호가 붙은 this()·super()는 생성자 호출로 반드시 첫 줄에 와야 한다.
쉽게 말하면this.name은 "내 이름", super.name은 "부모님 이름"이에요. 부모와 자식이 같은 이름의 필드를 가지고 있을 때 누구 것을 말하는지 구분해 주는 표지판입니다. 괄호가 붙으면 뜻이 달라져서, super()는 "부모님 먼저 준비시키기", this()는 "내 다른 생성자 먼저 부르기"가 됩니다.
점(.)이 붙는 this / super — 멤버 접근
this.멤버필드·this.메서드()는 자기 자신의 멤버를, super.멤버필드·super.메서드()는 부모의 멤버를 가리킵니다. 매개변수 이름과 필드 이름이 같을 때 this.name = name;처럼 구분하는 것이 가장 흔한 용도입니다(왼쪽 this.name은 필드, 오른쪽 name은 매개변수).
괄호가 붙는 this() / super() — 생성자 호출
this(값)은 같은 클래스의 다른 생성자를, super(값)은 부모의 생성자를 호출합니다. 둘 다 반드시 생성자의 첫 줄에 와야 하고, 그래서 한 생성자에서 둘을 동시에 쓸 수는 없습니다. 아무것도 안 쓰면 컴파일러가 super();를 자동으로 넣어 줍니다. (참고: JDK 25의 Flexible Constructor Bodies(JEP 513)부터는 아직 만들어지는 객체를 건드리지 않는 코드—예: 인자 검사—를 super()·this() 앞에 둘 수 있게 완화되었습니다. 이 프로젝트 기준인 Java 21에서는 여전히 첫 줄이어야 합니다.)
부모와 자식이 같은 이름의 필드를 가질 때
아래 Mother/Son 예제처럼 둘 다 name 필드를 가지면, 자식 안에서 name은 자식 것, super.name은 부모 것을 가리킵니다. 필드는 오버라이딩되지 않고 둘 다 메모리에 존재한다는 점이 메서드와 다릅니다.
참조타입 형 변환과 관계 용어
상속은 extends, 인터페이스 구현은 implements로 표현하며, 부모 쪽을 super·parent·base, 자식 쪽을 sub·child·derived라고 부릅니다. 방향으로 보면 위로 갈수록 추상화·일반화, 아래로 갈수록 구체화·상세화입니다. 부모 타입 → 자식 타입으로 되돌릴 때는 (Child)처럼 명시적 캐스팅이 필요하며, 실제 객체가 자식이 아니면 ClassCastException이 납니다. 그래서 캐스팅 전에 instanceof로 확인하는 것이 안전합니다.
JAVAThisSuperTest.java
class Person {
String name;
int age;
public Person(String name) {
this(name, 19); // ① 같은 클래스의 다른 생성자 호출 (첫 줄)
}
public Person(String name, int age) {
this.name = name; // ② this.필드 = 매개변수 (이름이 같을 때 구분)
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
}
class Mother {
protected String name;
public Mother(String name) { this.name = name; }
public void display() { System.out.println("name : " + name); }
}
class Son extends Mother {
private String name; // 부모와 같은 이름의 필드 — 둘 다 존재한다
public Son(String motherName, String myName) {
super(motherName); // ③ 부모 생성자 호출 (반드시 첫 줄)
this.name = myName;
}
@Override
public void display() {
System.out.println("mother name : " + super.name); // 부모의 name
System.out.println("my name : " + name); // 자식의 name
}
}
public class ThisSuperTest {
public static void main(String[] args) {
Person stu = new Person("홍길동");
System.out.println(stu.getName() + " " + stu.getAge()); // 홍길동 19
new Mother("mom").display();
new Son("mom", "son").display();
// ---- 참조타입 캐스팅: instanceof 로 확인 후 변환하는 것이 안전 ----
Mother m = new Son("mom", "son");
if (m instanceof Son) {
Son s = (Son) m;
s.display();
}
}
}
핵심 정리
this.멤버=자기 것, super.멤버=부모 것 / this()=내 다른 생성자, super()=부모 생성자.
this()·super()는 생성자의 첫 줄에만 올 수 있어 한 생성자에서 둘 다 쓸 수 없다.
필드는 오버라이딩되지 않고 부모·자식 것이 모두 존재한다.
부모 → 자식 캐스팅은 명시적으로 해야 하며 instanceof로 먼저 확인하는 것이 안전하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
this()와 super()를 생성자 첫 줄에만 쓸 수 있는 이유는?
초기화 순서를 보장하기 위해서입니다. 부모가 먼저 완전히 초기화된 뒤에 자식이 초기화되어야, 자식 생성자에서 부모의 필드를 안전하게 쓸 수 있습니다. 중간에 부르면 이 순서가 깨집니다.
업캐스팅은 자동인데 다운캐스팅은 명시해야 하는 이유는?
업캐스팅은 항상 안전하지만(자식은 부모의 모든 것을 가짐) 다운캐스팅은 실패할 수 있기 때문입니다. 부모 타입 변수가 실제로는 다른 자식일 수 있죠. 그래서 instanceof로 확인한 뒤 캐스팅하는 것이 안전합니다 — 안 그러면 ClassCastException입니다.
💼 실무·코딩테스트에서는실무에서 다운캐스팅이 자주 필요하다면 설계가 잘못됐다는 신호로 봅니다. 다형성을 제대로 쓰면 부모 타입으로 다루면서도 각자 다르게 동작하므로 캐스팅할 일이 없어야 하죠. 자바 16부터는 if (o instanceof Dog d)처럼 확인과 캐스팅을 한 번에 할 수 있습니다.
TIP생성자에서 this.name = name;의 this.를 빠뜨리면 매개변수에 매개변수를 대입하는 꼴이 되어 필드는 계속 null인 채로 남습니다. 컴파일 에러도 안 나서 찾기 어려운 대표적 실수입니다.
21
추상 클래스(Abstract)
abstract추상메서드상속강요객체생성금지
한 줄 요약abstract 메서드는 몸체 없이 선언만 하고 자식에게 구현을 강요하며, 추상 메서드가 하나라도 있는 클래스는 반드시 추상 클래스가 되고 new로 객체를 만들 수 없다.
쉽게 말하면추상 클래스는 빈칸이 있는 계약서예요. "코끼리는 먹는다(eat)"는 항목은 있지만 무엇을 어떻게 먹는지는 비워 둔 상태라서, 아시아코끼리·아프리카코끼리가 계약서를 물려받으면 그 빈칸을 반드시 채워야 합니다. 빈칸이 남아 있는 계약서 자체로는 계약(객체 생성)을 할 수 없습니다.
abstract의 규칙
① abstract 예약어로 선언합니다. ② 객체 생성 금지 — Parent p = new Parent();는 불가능합니다. ③ 상속 강요 — 상속받는 곳에서 추상 메서드를 반드시 구현(오버라이딩)해야 합니다. ④ 클래스 안에 abstract 메서드가 하나라도 있으면 그 클래스는 반드시 abstract class여야 합니다. ⑤ 반대로 추상 메서드가 없어도, 객체 생성을 막고 상속을 강요할 목적으로 abstract를 붙일 수 있습니다.
추상 클래스는 일반 멤버도 가질 수 있다
추상 클래스는 필드와 구현된 메서드, 생성자까지 가질 수 있습니다. 즉 "공통으로 쓸 부분은 미리 구현해 두고, 하위마다 달라지는 부분만 추상 메서드로 비워 두는" 구조를 만들 수 있습니다. 다만 Java 8부터는 인터페이스도 default 메서드로 구현된 메서드를 가질 수 있으므로, "구현 메서드가 있느냐"는 더 이상 결정적인 차이가 아닙니다. 인터페이스와의 진짜 차이는 ① 객체마다 달라지는 상태(인스턴스 필드)를 가질 수 있다(인터페이스 필드는 전부 상수) ② 생성자가 있다 ③ 클래스는 하나만 상속(extends)할 수 있다(인터페이스는 여러 개 구현 가능) — 이 세 가지입니다(22. 인터페이스).
다형성과 함께 쓰는 전형적 패턴
Magic m = new OddMagic(); m.make();처럼 추상 타입으로 참조하고 자식 구현을 실행하는 것이 핵심 활용법입니다. 호출하는 쪽은 "make()가 있다"는 것만 알면 되고, 실제 어떤 마방진 알고리즘이 도는지는 몰라도 됩니다 — 새로운 마방진 종류를 추가해도 호출 코드는 바뀌지 않습니다.
JAVAAbstractTest.java
abstract class Magic {
protected int[][] board; // 필드도 가질 수 있다
public Magic(int n) { board = new int[n][n]; } // 생성자도 가능
// 구현된 메서드: 자식이 공통으로 물려받아 그대로 쓴다
public void print() {
for (int[] row : board) {
for (int v : row) System.out.printf("%3d", v);
System.out.println();
}
}
// 추상 메서드: 몸체가 없다 → 자식이 반드시 구현해야 한다
public abstract void make();
}
class OddMagic extends Magic {
public OddMagic(int n) { super(n); }
@Override
public void make() { // 구현하지 않으면 컴파일 에러
System.out.println("홀수 마방진 생성 알고리즘 실행");
}
}
public class AbstractTest {
public static void main(String[] args) {
// Magic m0 = new Magic(3); // 에러! 추상 클래스는 객체 생성 불가
Magic m = new OddMagic(3); // 추상 타입으로 참조 + 자식 구현 실행
m.make(); // OddMagic의 make()가 호출된다(VMI)
m.print(); // 부모가 구현해 둔 공통 기능을 그대로 사용
}
}
핵심 정리
추상 메서드는 몸체가 없고, 하나라도 있으면 클래스도 abstract여야 한다.
추상 클래스는 new로 객체를 만들 수 없고(상속 전용), 상속받은 쪽에 구현을 강요한다.
필드·생성자·구현된 메서드를 모두 가질 수 있어 공통 코드를 물려줄 수 있다.
추상 타입으로 참조하고 자식 구현을 실행하는 다형성 패턴이 핵심 용도다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
추상 클래스로는 객체를 못 만드는데 왜 만드나?
공통 코드는 물려주고, 달라지는 부분만 자식이 채우게 하려는 것입니다. "동물"이라는 실체는 없지만 모든 동물이 공유하는 속성과 동작은 있죠. 미완성 부분(abstract 메서드)을 남겨 두면 자식이 반드시 구현하도록 강제할 수 있습니다.
추상 메서드가 하나도 없어도 abstract를 붙일 수 있나? 왜 그럴까?
붙일 수 있습니다. "이 클래스는 직접 객체로 만들지 말고 상속해서 쓰라"는 의도를 표현하기 위해서입니다. 추상 메서드가 없어도 abstract가 붙은 클래스는 new가 컴파일 단계에서 실제로 막히므로, 그 설계 의도를 문법으로 못 박는 용도입니다(다만 자식에게 특정 메서드 구현을 강요하는 효과는 추상 메서드가 있어야 생깁니다).
💼 실무·코딩테스트에서는실무에서 추상 클래스는 템플릿 메서드 패턴으로 자주 쓰입니다 — 전체 흐름은 부모가 정해 두고 일부 단계만 자식이 바꾸게 하는 방식이죠. 프레임워크가 "여기만 채우세요"라고 제공하는 클래스들이 대부분 이 구조입니다.
TIP추상 클래스를 상속받고도 추상 메서드를 구현하지 않으면, 그 자식도 추상 클래스가 되어야 합니다. "구현을 미루는" 중간 단계 클래스를 만들 때 이 성질을 활용합니다.
22
인터페이스(Interface)와 추상 클래스의 차이
interfaceimplements다중구현default 메서드is-a vs can-do
한 줄 요약인터페이스는 기능(행위)의 목록만 선언해 구현을 강요하는 "can-do" 계약으로 여러 개를 동시에 구현할 수 있고, 추상 클래스는 공통 필드·구현까지 물려주는 "is-a" 계층 구조에 쓴다.
쉽게 말하면추상 클래스는 가족(개는 동물이다 — is-a)이고 인터페이스는 자격증(개는 수영할 수 있다 — can-do)이에요. 가족은 하나만 가질 수 있지만(단일 상속) 자격증은 여러 개 딸 수 있습니다(다중 구현). 그래서 "새와 비행기는 가족은 아니지만 둘 다 난다"는 공통점은 Flyer라는 인터페이스로 묶습니다.
인터페이스의 기본 규칙
① 필드를 선언하면 자동으로 public static final 상수가 됩니다(int a = 1; → public final static int a = 1;). ② 메서드는 자동으로 public abstract가 되므로 void show();처럼 몸체 없이 이름만 나열합니다. ③ 인터페이스의 멤버는 생략해도 자동으로 public이며, protected는 어떤 버전에서도 쓸 수 없습니다. private은 원래 금지였지만 Java 9부터 private 메서드(default 메서드끼리 공통 코드를 나누는 용도)는 허용됩니다. ④ 구현하지 않으면 그 클래스는 추상 클래스가 되어야 합니다. ⑤ 상속과 함께 쓸 때는 extends를 먼저 쓰고 implements를 나중에 씁니다. ⑥ 인터페이스는 여러 개를 동시에 구현할 수 있습니다.
JDK 8 이후 추가된 것들
인터페이스를 수정하면 그것을 구현한 모든 기존 클래스가 깨지는 문제 때문에, 호환성을 유지하면서 유연하게 설계할 수 있도록 다음이 추가되었습니다 — default 메서드(인터페이스 안에서 기본 구현을 제공, 구현 클래스가 굳이 재정의하지 않아도 됨), static 메서드(정적 유틸 메서드 정의), 함수형 인터페이스(추상 메서드가 딱 하나여서 람다식으로 구현 가능), @FunctionalInterface 어노테이션(함수형 인터페이스임을 명시하고 컴파일러가 검사), 그리고 Java 9부터 private 메서드(여러 default·static 메서드가 같이 쓰는 코드를 인터페이스 안에 숨겨 두는 용도, 구현 클래스에서는 안 보임).
선언과 구현의 분리
인터페이스는 메서드 목록만 나열하고 실제 구현은 자식 쪽에서 합니다. Flyer fl = new Bird();처럼 인터페이스 타입으로 참조하면, 호출하는 쪽은 "난다(fly)"는 사실만 알고 그것이 새인지 비행기인지는 몰라도 됩니다. 이 분리 덕분에 나중에 구현체를 바꿔 끼워도 사용하는 코드는 그대로입니다.
언제 무엇을 쓰나 — 선택 기준
"A는 B다"라는 계층 구조가 필요할 때 → 추상 클래스(상속). "A는 B할 수 있다"는 행위를 정의할 때 → 인터페이스. 여러 객체가 특정 기능만 공유하게 하고 싶을 때 → 인터페이스. 공통 필드나 로직을 재사용하고 싶을 때 → 추상 클래스. 정리하면 추상 클래스는 is-a 관계 + 상태 공유 + 단일 상속, 인터페이스는 can-do 관계 + 동작 명세 + 다중 구현입니다.
JAVAInterfaceTest.java
// 추상 클래스: "is-a" — 공통 상태(필드)와 구현을 물려준다
abstract class Animal {
String name;
void breathe() { System.out.println("숨 쉰다"); }
abstract void cry();
}
// 인터페이스: "can-do" — 기능 명세만 나열
interface Swimmable {
int MAX_DEPTH = 100; // 자동으로 public static final 상수
void swim(); // 자동으로 public abstract
// float는 기본타입 이름(예약어)이라 메서드명으로 못 써서 float_로 지었다
default void float_() { // JDK 8+: 기본 구현 제공 (재정의 안 해도 됨)
System.out.println("물에 뜬다");
}
static String info() { // JDK 8+: static 메서드
return "수영 가능 인터페이스";
}
}
// 자동 import되는 java.lang.Runnable(스레드용)과 이름이 겹쳐 헷갈리지 않도록 Runnable2로 지었다
interface Runnable2 { void run(); }
// extends 를 먼저, implements 를 나중에. 인터페이스는 여러 개 가능
class Dog extends Animal implements Swimmable, Runnable2 {
@Override void cry() { System.out.println("멍멍"); }
@Override public void swim() { System.out.println("수영한다!"); } // 무조건 구현
@Override public void run() { System.out.println("달린다!"); }
}
public class InterfaceTest {
public static void main(String[] args) {
Dog d = new Dog();
d.breathe(); d.cry(); d.swim(); d.run();
d.float_(); // default 메서드 그대로 사용
// 인터페이스 타입으로 참조 — 구현체가 무엇인지 몰라도 된다
Swimmable s = d;
s.swim();
System.out.println(Swimmable.MAX_DEPTH + " " + Swimmable.info());
}
}
핵심 정리
인터페이스의 필드는 자동 public static final, 메서드는 자동 public abstract.
클래스는 하나만 extends 하지만 인터페이스는 여러 개 implements 할 수 있다(순서: extends → implements).
JDK 8+ : default 메서드 · static 메서드 · 함수형 인터페이스 · @FunctionalInterface / JDK 9+ : private 메서드. 멤버는 자동 public이고 protected는 불가.
추상 클래스 = is-a + 상태/구현 공유, 인터페이스 = can-do + 동작 명세 + 다중 구현.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
추상 클래스와 인터페이스를 무엇으로 구분해 쓰나?
"~이다(is-a)"면 추상 클래스, "~할 수 있다(can-do)"면 인터페이스입니다. Dog extends Animal(개는 동물이다)과 Dog implements Runnable(개는 달릴 수 있다)의 차이죠. 또 클래스는 하나만 상속되지만 인터페이스는 여러 개 구현할 수 있습니다.
인터페이스에 default 메서드가 추가된 이유는?
기존 코드를 깨뜨리지 않고 인터페이스에 기능을 추가하기 위해서입니다. 인터페이스에 메서드를 하나 추가하면 그걸 구현한 모든 클래스가 컴파일 에러가 나거든요. default로 기본 구현을 제공하면 기존 클래스는 그대로 두고 새 기능을 넣을 수 있습니다.
💼 실무·코딩테스트에서는실무 설계는 대부분 인터페이스 중심입니다. "구현이 아니라 인터페이스에 의존하라"는 원칙 덕분에, 테스트할 때 가짜 구현으로 갈아 끼우고 나중에 구현을 통째로 바꿔도 쓰는 쪽 코드는 그대로입니다. 스프링의 의존성 주입이 정확히 이 원리 위에 있습니다.
TIP🧪 마방진 과제의 클래스 다이어그램이 이 둘을 함께 쓰는 좋은 예입니다 — MagicSquare(추상 클래스, 공통 필드 magic[][]과 합 검증 로직 공유) + Interface_Magic(make/print 명세) + MagicFactory(싱글턴 팩토리)의 조합입니다.
23
중첩 클래스(Nested Class)
static 중첩내부 클래스지역 클래스익명 클래스
한 줄 요약클래스 안에 정의하는 클래스로, static 중첩(독립적)·인스턴스 내부(외부 객체에 종속)·지역(메서드 안)·익명(이름 없이 즉시 생성) 네 종류가 있고 종류에 따라 외부 멤버 접근 범위가 다르다.
쉽게 말하면중첩 클래스는 큰 서랍 안의 작은 칸이에요. 그 클래스 안에서만 쓰이는 보조 클래스를 밖에 따로 두면 파일만 늘어나니, 쓰이는 자리 바로 옆에 두는 겁니다. 익명 클래스는 특히 일회용 종이컵 같아서, 이름도 안 붙이고 한 번 쓰고 버립니다.
왜 쓰는가
논리적으로 연관된 클래스들을 그룹화해 코드의 가독성을 높이고 캡슐화를 강화합니다. 외부에서는 쓸 일이 없고 특정 클래스 안에서만 의미가 있는 보조 클래스를 만들 때 적합합니다.
4가지 종류와 접근 범위
정적 중첩 클래스(static class C {}): 독립적으로 사용 가능하며 외부의 static 멤버만 직접 접근할 수 있습니다(바깥 클래스의 객체를 받아 outer.a처럼 쓰면 private 인스턴스 멤버에도 접근할 수 있습니다). 유틸리티 역할에 적합합니다. 인스턴스 내부 클래스(class B {}): 외부 클래스의 인스턴스에 종속되어 모든 멤버에 접근 가능하고, 외부 객체를 먼저 만들어야 생성할 수 있습니다. 지역 내부 클래스: 메서드 안에서 정의되며, 그 메서드의 지역변수·매개변수는 final 또는 effectively final인 것만 쓸 수 있습니다(바깥 클래스의 필드는 이 제한 없이 읽고 바꿀 수 있습니다). 익명 내부 클래스: 이름 없이 즉시 생성하며 1회성 사용, 이벤트 핸들링, 인터페이스 구현에 씁니다.
effectively final이란
final로 선언하지는 않았지만 값이 한 번 할당된 뒤 다시 바뀌지 않는 변수를 말합니다. 지역 내부 클래스와 익명 클래스는 메서드가 끝난 뒤에도 살아남을 수 있는데, 그 시점에 지역변수는 스택에서 사라지므로 값이 변하지 않는 변수만 캡처할 수 있게 제한한 것입니다.
익명 클래스 → 람다식으로 가는 다리
인터페이스의 추상 메서드가 하나뿐이라면 익명 클래스로 구현하는 코드가 장황해집니다. 이를 짧게 줄인 것이 람다식입니다 — new MyLambda(){ public void show(){...} }가 () -> {...}로 줄어듭니다. 익명 클래스를 이해하면 람다식이 자연스럽게 이해됩니다.
JAVANestedTest.java
interface Greeting { void hello(); }
public class NestedTest {
public int a = 10;
public static int s = 20;
class B { // ① 인스턴스 내부 클래스 — 모든 멤버 접근 가능
void print() { System.out.println(a + " " + s); }
}
static class C { // ② 정적 중첩 클래스 — static 멤버만 직접 접근 가능
void print() { System.out.println(s); }
}
public void method() {
int d = 30; // 재할당하지 않으므로 effectively final
class D { // ③ 지역 내부 클래스 — 메서드 안에서만 존재
void print() { System.out.println(d); }
}
new D().print();
// ④ 익명 내부 클래스 — 이름 없이 즉시 구현하고 즉시 사용
Greeting g = new Greeting() {
@Override public void hello() { System.out.println("안녕! (익명 클래스)"); }
};
g.hello();
// 같은 것을 람다식으로 (추상 메서드가 하나뿐이므로 가능)
Greeting g2 = () -> System.out.println("안녕! (람다식)");
g2.hello();
}
public static void main(String[] args) {
NestedTest outer = new NestedTest();
NestedTest.B b = outer.new B(); // 인스턴스 내부 클래스는 외부 객체가 필요
b.print();
new NestedTest.C().print(); // 정적 중첩 클래스는 독립적으로 생성 가능
outer.method();
}
}
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
중첩 클래스를 쓰는 이유는 무엇인가?
그 클래스 안에서만 의미 있는 것을 밖으로 꺼내지 않기 위해서입니다. LinkedList 안의 Node처럼요. 밖으로 빼면 이름 공간을 오염시키고 누가 쓸지 알 수 없게 됩니다. 안에 두면 바깥 클래스의 private 멤버에 접근할 수도 있습니다.
static 중첩 클래스와 (비static) 내부 클래스의 차이는?
바깥 객체가 필요한가의 차이입니다. 내부 클래스는 바깥 인스턴스에 연결되어 있어 바깥 객체 없이는 만들 수 없고, 그래서 바깥 객체를 붙잡아 메모리 누수의 원인이 되기도 합니다. 그런 연결이 필요 없으면 static을 붙이는 것이 권장됩니다.
💼 실무·코딩테스트에서는실무에서는 익명 클래스와 람다로 자주 만납니다. 이벤트 처리기나 Comparator를 그 자리에서 만들 때죠. 자바 8 이후로는 람다가 익명 클래스를 대체하는 경우가 많지만, 상태를 가져야 하면 여전히 중첩 클래스가 필요합니다.
TIP중첩 클래스를 남발하면 코드가 오히려 읽기 어려워집니다. "이 클래스가 바깥 클래스 없이도 의미가 있는가?"를 기준으로, 의미가 있다면 밖으로 빼는 것이 좋습니다.
24
제네릭(Generic)
<T>타입 안정성Bounded Type와일드카드
한 줄 요약제네릭은 클래스·인터페이스·메서드를 정의할 때 사용할 타입을 나중에 정하게 하는 기능으로, 컴파일 시점에 타입을 검사해 형변환 없이 안전하게 쓰고 같은 로직을 여러 타입에 재사용할 수 있게 한다.
쉽게 말하면제네릭은 "내용물 라벨을 붙일 수 있는 상자"예요. 라벨 없는 상자(List)는 아무거나 들어가서 꺼낼 때마다 "이게 뭐였지?" 하고 확인(형변환)해야 하고, 잘못 꺼내면 실행 중에 터집니다. 라벨을 붙이면(List<String>) 상자에 다른 걸 넣으려 할 때 포장 단계(컴파일)에서 막아 줍니다.
왜 필요한가 — 타입 안정성과 재사용성
제네릭 없이 List list = new ArrayList();를 쓰면 모든 것이 Object로 처리되어, 꺼낼 때마다 (String) list.get(0)처럼 강제 형변환이 필요하고 잘못된 타입을 넣어도 컴파일러가 못 잡습니다. List<String>으로 쓰면 컴파일 시점에 타입을 검사해 실행 시 오류를 예방하고, 형변환도 필요 없습니다. 또 같은 로직을 여러 타입에 재사용할 수 있어 코드 중복이 줄어듭니다.
제네릭 클래스·인터페이스·메서드
class Box<T>처럼 클래스명 뒤에 타입 매개변수를 선언하면, 객체를 만들 때 Box<String>·Box<Integer>처럼 실제 타입이 결정됩니다. 인터페이스에도 interface Printer<T>처럼 쓸 수 있고 구현체가 implements Printer<String>으로 명시합니다. 클래스가 제네릭이 아니어도 메서드 선언부 앞에 <T>를 붙여 메서드만 제네릭으로 만들 수도 있습니다.
타입 매개변수 이름 관례
T(Type, 일반적인 타입) · E(Element, 컬렉션 요소 — List<E>) · K(Key, 맵의 키) · V(Value, 맵의 값 — Map<K,V>) · N(Number, 숫자 타입). 강제는 아니지만 표준 API가 이 관례를 따르므로 맞춰 쓰는 것이 읽기 좋습니다.
제한된 타입(Bounded Type)과 와일드카드
<T extends Number>처럼 쓰면 Number의 하위 타입만 허용되고, 그 덕분에 Number의 메서드(doubleValue() 등)를 T에서 쓸 수 있습니다. ?는 모든 타입을 뜻하는 와일드카드로, ? extends T는 읽기 전용(T의 하위 중 어느 타입이 올지 컴파일러가 모르므로 add 불가, 읽을 때는 T로 받으면 안전), ? super T는 쓰기 가능(T의 부모가 명확하므로 T를 넣는 건 안전, 꺼내면 Object로만 받을 수 있음)입니다.
JAVAGenericTest.java
import java.util.*;
// ---- 제네릭 클래스 ----
class Box<T> {
private T item;
public void set(T item) { this.item = item; }
public T get() { return item; }
}
// ---- 제네릭 인터페이스 ----
interface Printer<T> { void print(T value); }
class StringPrinter implements Printer<String> {
public void print(String value) { System.out.println("String: " + value); }
}
// ---- 제한된 타입: Number의 하위 타입만 허용 ----
class NumberBox<T extends Number> {
private T num;
public void set(T num) { this.num = num; }
public void printDouble() { System.out.println(num.doubleValue()); } // Number의 메서드 사용
}
public class GenericTest {
// ---- 제네릭 메서드: 클래스가 제네릭이 아니어도 메서드만 제네릭으로 ----
public static <T> void printAll(List<T> list) {
for (T t : list) System.out.print(t + " ");
System.out.println();
}
public static void main(String[] args) {
// 제네릭 미적용 — Object로 처리되어 형변환이 필요하고 실수를 못 잡는다
List raw = new ArrayList();
raw.add("hello");
String s0 = (String) raw.get(0); // 강제 형변환 필요
// 제네릭 적용 — 형변환 불필요, 잘못된 타입은 컴파일 에러
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(10); // 컴파일 에러로 미리 막아 준다
String s1 = list.get(0);
System.out.println(s0 + s1);
Box<String> strBox = new Box<>(); strBox.set("Hello");
Box<Integer> intBox = new Box<>(); intBox.set(123);
System.out.println(strBox.get() + " " + intBox.get());
new StringPrinter().print("제네릭 인터페이스");
NumberBox<Integer> nb = new NumberBox<>();
nb.set(42); nb.printDouble(); // 42.0
// ---- 와일드카드 ----
List<? extends Number> numbers = new ArrayList<Integer>();
// numbers.add(10); // 에러! 읽기 전용
List<? super Integer> ints = new ArrayList<Number>();
ints.add(4); // 추가 가능
Object o = ints.get(0); // 꺼내면 Object로만 받을 수 있다
System.out.println(o);
printAll(Arrays.asList(1, 2, 3));
}
}
핵심 정리
제네릭 = 타입을 나중에 정하는 기능 → 컴파일 시 타입 검사(타입 안정성) + 코드 재사용성.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
제네릭이 없던 시절과 비교해 무엇이 좋아졌나?
타입 오류를 실행 전에 잡을 수 있게 됐습니다. 예전엔 List에서 꺼낼 때마다 (String)으로 캐스팅했고, 엉뚱한 게 들어 있으면 실행 중에 ClassCastException이 났죠. 제네릭은 컴파일 시점에 막아 주고 캐스팅도 없앱니다.
List<Object>에 List<String>을 대입할 수 없는 이유는?
대입이 된다면 그 리스트에 Integer를 넣을 수 있게 되어 타입 안전성이 깨지기 때문입니다. List<Object>로 보면 뭐든 넣을 수 있으니까요. 그래서 제네릭은 상속 관계가 그대로 이어지지 않고, 필요하면 ? extends / ? super 같은 와일드카드를 씁니다.
💼 실무·코딩테스트에서는제네릭은 컬렉션을 쓰는 순간 매일 만나는 문법입니다. 면접에서는 타입 소거(type erasure)를 자주 묻습니다 — 제네릭 정보는 컴파일 후 지워지므로 실행 중에는 List<String>인지 List<Integer>인지 알 수 없다는 사실입니다.
TIPnew ArrayList<>()처럼 뒤쪽 꺾쇠를 비워 두는 것(다이아몬드 연산자)은 Java 7부터 가능하며, 왼쪽 선언부의 타입을 컴파일러가 추론해 줍니다. 🛠️ 컬렉션에서 계속 쓰게 됩니다.
🛠️ 3. 자바 프로그래밍 활용
컬렉션 프레임워크(List·Set·Map), 예외 처리, 람다식과 Stream, 입출력(IO), 스레드 — 배열과 반복문만으로는 감당하기 어려운 실전 문제를 표준 API로 푸는 단원입니다.
01
컬렉션 프레임워크(JFC) 개요 — List · Set · Map
CollectionListSetMapIterator
한 줄 요약컬렉션은 크게 List(순서 있고 중복 허용)·Set(중복 불가, 순서는 인터페이스가 보장하지 않음)·Map(키-값 쌍, 키 중복 불가) 세 계열로 나뉘며, 담을 데이터의 성격에 따라 골라 쓴다.
쉽게 말하면List는 번호표가 붙은 줄(같은 사람이 두 번 서도 됨), Set은 회원 명부(중복 등록 불가, 순서 무의미), Map은 사전(단어=키로 뜻=값을 찾음)이에요. 배열은 크기가 고정이라 불편한데, 컬렉션은 필요할 때마다 늘어나는 자료구조입니다.
세 계열의 성격
List: 순서가 있고(인덱스로 접근) 중복을 허용합니다. 구현 클래스는 ArrayList, Vector, LinkedList, Stack 등. Set: 중복이 안 되고, Set 인터페이스 자체는 순서를 보장하지 않습니다(구현체에 따라 다름 — HashSet은 순서 없음, LinkedHashSet은 넣은 순서, TreeSet은 정렬 순서). 구현 클래스는 HashSet, LinkedHashSet, TreeSet 등. Map: 키(Key)와 값(Value)을 한 쌍으로 저장하며 키는 중복 불가, 값은 중복 가능합니다. 구현 클래스는 HashMap, LinkedHashMap, TreeMap, Hashtable 등. List·Set은 Collection 인터페이스를 상속하지만 Map은 Collection 계열이 아닙니다.
List: 학생 명단(순서대로, 동명이인 허용), 게시글 목록(최신순), 검색어 기록(시간순, 중복 허용), 장바구니(같은 상품 여러 개), 재생 목록, 채팅 메시지. Set: 태그 목록(중복 제거), 이메일 수신자(중복 발송 방지), 수강 강의 목록, 추천 상품 ID, 친구 목록, 로또 번호(번호는 중복 불가). Map: 사용자 ID→회원 정보, 상품 ID→가격, 날짜→방문자 수, 국가 코드→국가명, 설정 키→설정값.
Iterator — 공통 순회 도구
iterator()로 반복자를 얻어 hasNext()로 남았는지 확인하고 next()로 하나씩 꺼냅니다. 인덱스가 없는 Set이나 Map의 keySet을 순회할 때 특히 유용하며, 순회 중 안전하게 삭제할 수 있는 remove()도 제공합니다(향상된 for문으로 순회하며 삭제하면 ConcurrentModificationException이 납니다).
JAVACollectionOverview.java
import java.util.*;
public class CollectionOverview {
public static void main(String[] args) {
// ---- List: 순서 O, 중복 O ----
List<String> list = new ArrayList<>();
list.add("A"); list.add("B"); list.add("B"); // 중복 허용
System.out.println(list.get(0) + " " + list.size()); // A 3
// ---- Set: 중복 X (HashSet은 순서를 보장하지 않는다) ----
Set<String> set = new HashSet<>();
set.add("가"); set.add("나"); set.add("나"); // 중복은 무시된다
System.out.println(set.size()); // 2
// ---- Map: key-value, key 중복 X ----
Map<String, String> map = new HashMap<>();
map.put("A", "값1");
map.put("B", "값1"); // 값은 중복 가능
map.put("B", "값3"); // 같은 키를 다시 넣으면 마지막 값으로 덮어쓴다
System.out.println(map.get("A") + " " + map.get("B")); // 값1 값3
// ---- Iterator로 순회 (Set처럼 인덱스가 없을 때 유용) ----
Iterator<String> iter = set.iterator();
while (iter.hasNext()) System.out.print(iter.next() + " ");
System.out.println();
// ---- Map은 keySet()으로 키를 Set에 담아 순회한다 ----
Set<String> keys = map.keySet();
for (String k : keys) System.out.print(k + "=" + map.get(k) + " ");
}
}
핵심 정리
List = 순서 O·중복 O / Set = 중복 X·순서는 구현체에 따라 다름(인터페이스는 보장 X) / Map = 키-값, 키 중복 X·값 중복 O.
Map은 Collection 인터페이스를 상속하지 않는 별도 계열이다.
같은 키로 put하면 덮어쓰기가 된다(마지막 값이 최종값).
Iterator는 인덱스 없는 자료구조를 순회하고 안전하게 삭제하는 공통 도구다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
List·Set·Map을 고르는 기준을 한 문장씩으로 말해보세요.
List는 순서가 있고 중복을 허용(장바구니), Set은 중복을 막음(방문한 아이디 — 순서는 구현체에 따라 다름), Map은 키로 값을 찾음(학번→학생). 문제를 읽고 "중복이 의미 있는가, 순서가 필요한가, 무엇으로 찾는가"를 물으면 자동으로 결정됩니다.
컬렉션이 배열보다 나은 점과 배열이 여전히 나은 점은?
컬렉션은 크기가 자동으로 늘고 삽입·삭제 메서드가 갖춰져 있습니다. 반면 배열은 기본타입을 그대로 담아 메모리가 적고 접근이 빠릅니다. 그래서 크기가 고정된 대량 수치 계산에는 배열이 유리합니다.
💼 실무·코딩테스트에서는실무에서 자료구조 선택이 성능을 좌우합니다. "리스트에서 contains를 반복 호출"하는 코드는 O(N²)이 되는데, Set으로 바꾸면 O(N)이 됩니다. 코딩테스트에서 시간 초과가 나면 가장 먼저 의심할 지점이기도 합니다.
TIP컬렉션은 객체만 담을 수 있어List<int>는 불가능하고 List<Integer>로 써야 합니다(Wrapper 클래스). 넣고 꺼낼 때 오토박싱/언박싱이 자동으로 일어납니다.
02
List 계열 — ArrayList vs LinkedList vs Vector
ArrayListLinkedListVector동기화
한 줄 요약ArrayList는 배열 기반이라 조회가 빠르고 중간 삽입·삭제가 느리며, LinkedList는 이중 연결 리스트라 중간 삽입·삭제가 빠르고 조회가 느리다. Vector는 동기화를 지원하지만 느린 레거시 클래스다.
쉽게 말하면ArrayList는 영화관 좌석이에요. 12번 좌석을 바로 찾아갈 수 있지만(빠른 조회), 중간에 한 명을 끼워 넣으려면 뒤에 앉은 사람들이 전부 한 칸씩 밀려나야 합니다. LinkedList는 손을 잡고 늘어선 줄이라, 중간에 끼워 넣을 땐 양쪽 손만 다시 잡으면 되지만(빠른 삽입) 12번째 사람을 찾으려면 처음부터 세면서 가야 합니다.
내부 구조의 차이
ArrayList는 배열 기반이라 데이터가 연속된 공간에 저장됩니다. LinkedList는 이중 연결 리스트(Double Linked List)로 노드 단위로 비연속 저장되며, 각 노드가 앞뒤 노드의 주소를 함께 가집니다.
성능 비교
조회(get/set): ArrayList 빠름(인덱스로 바로 접근) / LinkedList 느림(처음부터 따라감). 중간 삽입·삭제: ArrayList 느림(뒤 요소를 전부 밀어야 함) / LinkedList 빠름(링크만 수정). 끝에 추가(add): 둘 다 빠름. 메모리: ArrayList가 효율적(데이터만 저장) / LinkedList는 포인터 2개를 추가로 저장해 비효율적. 결론적으로 검색이 잦으면 ArrayList, 삽입·삭제가 잦으면 LinkedList가 유리합니다.
ArrayList vs Vector
ArrayList는 동기화되지 않아(스레드 안전 X) 여러 스레드가 동시에 고치면 데이터가 깨질 수 있지만, 잠금 비용이 없어 속도가 빠릅니다. Vector는 동기(synchronized)라 한 번에 한 스레드만 접근 가능해 스레드에 안전하지만 느립니다. Vector는 Java 1.0부터 있던 레거시 클래스라 현재는 권장되지 않고, 대부분 이전 코드 호환용으로만 남아 있습니다.
동기화가 필요하다면
Vector 대신 Collections.synchronizedList(new ArrayList<>())처럼 Collections의 synchronized* 메서드로 감싸는 방식을 씁니다. List·Set·Map 모두 같은 방식으로 동기화 버전을 만들 수 있습니다.
JAVAListCompare.java
import java.util.*;
public class ListCompare {
public static void main(String[] args) {
// ---- ArrayList: 중간 삽입 시 뒤 요소들이 밀려난다 ----
List<String> list = new ArrayList<>(Arrays.asList("A","B","C","D"));
list.add(2, "I"); // 2번 자리에 삽입 → C, D가 뒤로 밀린다
System.out.println(list); // [A, B, I, C, D]
// ---- LinkedList: 링크만 수정되므로 나머지는 영향을 받지 않는다 ----
List<String> link = new LinkedList<>(Arrays.asList("A","B","C","D"));
link.add(2, "I");
System.out.println(link); // [A, B, I, C, D] (결과는 같지만 내부 동작이 다르다)
// ---- 주요 메서드 ----
System.out.println(list.get(0)); // A
System.out.println(list.indexOf("I")); // 2
System.out.println(list.contains("Z")); // false
System.out.println(list.subList(1, 3)); // [B, I]
list.remove("I"); // 값으로 삭제
list.remove(0); // 인덱스로 삭제
System.out.println(list + " size=" + list.size());
// ---- 동기화가 필요하면 Vector 대신 이 방식 ----
List<String> safeList = Collections.synchronizedList(new ArrayList<>());
Map<String,String> safeMap = Collections.synchronizedMap(new HashMap<>());
safeList.add("thread-safe");
System.out.println(safeList + " " + safeMap);
}
}
핵심 정리
ArrayList = 배열 기반, 조회 빠름·중간 삽입 느림 / LinkedList = 연결 리스트, 그 반대.
끝에 추가하는 것은 둘 다 빠르고, 메모리는 ArrayList가 효율적이다.
이론과 달리 실제로는 대부분 ArrayList가 빠르다 — LinkedList는 삽입 위치를 찾아가는 데 O(N)이 걸리고 캐시 효율도 나쁘기 때문(그래서 기본 선택은 ArrayList).
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
ArrayList와 LinkedList의 성능 차이가 실제로 갈리는 상황은?
중간 삽입·삭제가 잦으면 LinkedList, 인덱스 접근이 잦으면 ArrayList가 유리합니다. ArrayList는 배열 기반이라 인덱스 접근이 O(1)이지만 중간에 넣으면 뒤를 전부 밀어야 합니다. LinkedList는 반대죠.
그런데 실무에서 LinkedList를 거의 안 쓰는 이유는?
이론과 실제가 다르기 때문입니다. LinkedList는 중간 삽입이 O(1)이라지만 그 위치를 찾아가는 데 O(N)이 걸리고, 노드마다 참조를 저장해 메모리도 더 쓰며 캐시 효율이 나쁩니다. 그래서 대부분 ArrayList가 더 빠릅니다.
💼 실무·코딩테스트에서는면접에서 "ArrayList와 LinkedList 중 무엇을 쓰겠습니까"를 물으면, 이론적 차이만 답하기보다 "실제로는 대부분 ArrayList가 낫고, 그 이유는 캐시 지역성과 탐색 비용"까지 말하면 훨씬 좋습니다. Vector는 동기화 때문에 느려 레거시 취급이라는 것도 알아 두세요.
TIPList<Integer>에서 list.remove(1)과 list.remove(Integer.valueOf(1))은 완전히 다릅니다 — 앞은 인덱스 1번 칸을, 뒤는 값 1을 찾아 삭제합니다. 숫자 리스트에서 값으로 지우려면 Integer 객체로 넘겨야 하니 주의하세요.
03
Set 계열 — HashSet vs LinkedHashSet vs TreeSet
HashSetTreeSet해시함수중복 제거
한 줄 요약Set은 중복을 허용하지 않으며, HashSet은 순서가 없고 가장 빠르고, LinkedHashSet은 입력 순서를 유지하며, TreeSet은 자동 정렬(Comparator로 기준 변경 가능)된다.
쉽게 말하면Set은 중복을 자동으로 걸러 주는 체예요. HashSet은 아무 순서로 담는 바구니(가장 빠름), LinkedHashSet은 넣은 순서를 기억하는 바구니, TreeSet은 넣을 때마다 알아서 정렬해 주는 책장입니다.
해시 구조 — 왜 빠른가
키 값을 해시 함수에 넣으면 배열의 인덱스가 나오고, 그 위치에 값을 저장합니다. 그래서 값을 찾을 때 처음부터 훑지 않고 계산 한 번으로 위치를 알아냅니다(평균 O(1)). 서로 다른 키가 같은 인덱스를 반환하는 충돌이 생길 수 있는데, 그 자리에 연결 리스트를 달아 여러 값을 저장하는 방식으로 해결합니다.
세 구현체 비교
HashSet: Hash Table 구조, 순서 없음(무작위), 탐색 빠름, 메모리 적음, null 1개 허용. LinkedHashSet: Hash Table + Linked List, 입력 순서 유지, 탐색 빠름, 순서 링크 때문에 메모리 약간 더 사용. TreeSet: Red-Black Tree(균형 이진 트리), 자동 정렬, 탐색이 상대적으로 느림(O(log n)), 메모리 많이 사용, 기본적으로 null 불가(예외 발생), Comparator로 정렬 기준을 바꿀 수 있음.
선택 기준
빠른 삽입·검색으로 중복만 제거하면 → HashSet. 중복 제거 + 입력 순서 유지 → LinkedHashSet. 정렬된 집합이 필요 → TreeSet. 정렬 기준을 커스터마이징하고 싶다 → TreeSet + Comparator.
중복 판정의 기준
Set이 중복을 걸러내는 기준은 hashCode()와 equals()입니다. 내가 만든 클래스를 HashSet에 넣을 때 이 둘을 오버라이드하지 않으면, 내용이 같은 객체도 서로 다른 것으로 취급되어 중복이 저장됩니다(02. Object 4대 메서드).
JAVASetCompare.java
import java.util.*;
public class SetCompare {
public static void main(String[] args) {
String[] data = {"다", "가", "나", "가"};
Set<String> hash = new HashSet<>(Arrays.asList(data));
Set<String> linked = new LinkedHashSet<>(Arrays.asList(data));
Set<String> tree = new TreeSet<>(Arrays.asList(data));
System.out.println(hash); // [가, 나, 다] 순서 보장 없음 (중복 "가"는 제거)
System.out.println(linked); // [다, 가, 나] 입력 순서 유지
System.out.println(tree); // [가, 나, 다] 자동 정렬
// TreeSet + Comparator: 정렬 기준 바꾸기 (내림차순)
Set<String> desc = new TreeSet<>(Comparator.reverseOrder());
desc.addAll(Arrays.asList(data));
System.out.println(desc); // [다, 나, 가]
// ---- 실전: 로또 번호처럼 "중복 없는 6개"를 만들 때 ----
Set<Integer> lotto = new TreeSet<>(); // TreeSet이면 정렬까지 덤으로
while (lotto.size() < 6) {
lotto.add((int)(Math.random() * 45) + 1); // 중복이면 add가 무시된다
}
System.out.println(lotto);
// ---- 순회: 향상된 for 또는 Iterator ----
for (int n : lotto) System.out.print(n + " ");
System.out.println();
Iterator<Integer> it = lotto.iterator();
while (it.hasNext()) System.out.print(it.next() + " ");
}
}
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
HashSet이 중복을 걸러내는 원리를 설명해보세요.
hashCode()로 저장 위치를 정하고, 같은 자리에 뭔가 있으면 equals()로 확인합니다. 그래서 두 메서드를 제대로 재정의하지 않으면 중복이 안 걸러집니다 — 내용이 같아도 해시가 다르면 다른 칸에 저장되니까요.
TreeSet이 자동 정렬되는 대신 치르는 비용은?
이진 탐색 트리 구조라 삽입·검색이 O(log N)입니다. HashSet의 O(1)보다 느리죠. 또 담을 객체가 비교 가능해야 합니다(Comparable 구현 또는 Comparator 제공). 정렬이 꼭 필요할 때만 쓰는 것이 맞습니다.
💼 실무·코딩테스트에서는코딩테스트에서 "중복 제거"가 나오면 반사적으로 HashSet, "중복 제거 + 정렬"이면 TreeSet을 떠올리면 됩니다. 입력 순서를 유지해야 하면 LinkedHashSet입니다 — 이 세 가지 구분이 문제 푸는 속도를 크게 바꿉니다.
TIP🧪 로또 문제를 배열로 풀면 "이미 뽑은 번호인지" 매번 반복문으로 확인해야 하지만, TreeSet을 쓰면 중복 제거와 정렬이 동시에 해결됩니다. 수업에서는 배열로 먼저 풀고 나서 Set으로 다시 풀어보면 컬렉션의 가치를 확실히 느낄 수 있습니다.
04
Map 계열 — HashMap vs LinkedHashMap vs TreeMap
HashMapTreeMapkeySetput/get
한 줄 요약Map은 키-값 쌍으로 저장하고 키는 중복될 수 없으며, HashMap은 가장 빠르고 LinkedHashMap은 입력 순서를 유지하고 TreeMap은 키를 자동 정렬한다.
쉽게 말하면Map은 이름표가 붙은 사물함이에요. 번호(인덱스) 대신 이름(키)으로 물건을 찾습니다. 같은 이름표를 두 개 만들 수 없으므로(키 중복 불가), 같은 이름표에 새 물건을 넣으면 기존 물건이 교체됩니다.
기본 동작
put(key, value)로 넣고 get(key)로 꺼냅니다. 키는 중복 불가라서 같은 키로 다시 put하면 마지막 값으로 덮어씁니다. 값은 중복 가능합니다. 전체를 순회하려면 keySet()으로 키들을 Set에 담아 반복하거나, entrySet()으로 키-값 쌍을 한 번에 꺼냅니다.
세 구현체 비교
HashMap: Hash Table, 순서 없음, 탐색·삽입 O(1) 평균으로 빠름, null 키 1개·null 값 허용. LinkedHashMap: Hash Table + Linked List, 입력 순서 유지, 성능은 HashMap과 비슷. TreeMap: Red-Black Tree, 키 기준 자동 정렬, O(log n)으로 상대적으로 느림, null 키 불가(NullPointerException), Comparator로 정렬 기준 지정 가능, 키 범위 탐색(headMap·tailMap·subMap)이 가능합니다.
선택 기준
빠르게 넣고 빼야 한다면 HashMap, 입력 순서를 유지해야 한다면 LinkedHashMap, 키를 자동 정렬하거나 키 범위를 탐색해야 한다면 TreeMap입니다. 참고로 Hashtable은 Vector처럼 동기화되는 레거시 클래스입니다.
실전 활용 예
사용자 ID → 회원 정보 객체, 상품 ID → 가격, 날짜 → 방문자 수(일별 통계), 국가 코드 → 국가 이름("KR"→"대한민국"), 카테고리 → 상품 리스트(Map<String, List<String>>처럼 값에 컬렉션을 넣을 수도 있습니다), 설정 키 → 환경 설정값("theme"→"dark").
JAVAMapCompare.java
import java.util.*;
public class MapCompare {
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("A", "값1");
map.put("B", "값1"); // 값은 중복 가능
map.put("B", "값3"); // 키 중복 → 마지막 값으로 덮어쓰기
System.out.println(map.get("A")); // 값1
System.out.println(map.get("Z")); // null (없는 키는 null)
System.out.println(map.getOrDefault("Z", "기본값")); // 기본값
// ---- keySet()으로 순회 ----
Set<String> keys = map.keySet();
for (String k : keys) System.out.print(k + "=" + map.get(k) + " ");
System.out.println();
// ---- entrySet()으로 키·값을 한 번에 ----
for (Map.Entry<String, String> e : map.entrySet())
System.out.print(e.getKey() + "→" + e.getValue() + " ");
System.out.println();
// ---- 세 구현체의 순서 차이 ----
Map<String,Integer> hash = new HashMap<>();
Map<String,Integer> linked = new LinkedHashMap<>();
Map<String,Integer> tree = new TreeMap<>();
for (Map<String,Integer> m : Arrays.asList(hash, linked, tree)) {
m.put("다", 3); m.put("가", 1); m.put("나", 2);
}
System.out.println(hash); // 순서 보장 없음
System.out.println(linked); // {다=3, 가=1, 나=2} 입력 순서
System.out.println(tree); // {가=1, 나=2, 다=3} 키 정렬
// ---- 단어 개수 세기: Map의 대표 활용 ----
String[] words = {"java", "css", "java", "html", "java"};
Map<String,Integer> count = new HashMap<>();
for (String w : words) count.put(w, count.getOrDefault(w, 0) + 1);
System.out.println(count); // {java=3, css=1, html=1}
}
}
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
Map의 key로 직접 만든 클래스를 쓰려면 무엇이 필요한가?
equals()와 hashCode()를 함께 재정의해야 합니다. 안 그러면 내용이 같은 키로 조회해도 못 찾습니다 — 넣을 때와 찾을 때의 해시가 달라 다른 칸을 보게 되니까요. 그리고 키는 불변이어야 안전합니다(넣은 뒤 값이 바뀌면 영영 못 찾습니다).
getOrDefault가 유용한 이유를 카운팅 예로 설명해보세요.
map.put(k, map.getOrDefault(k, 0) + 1)이면 처음 보는 키는 0에서 시작, 있으면 +1이 한 줄로 됩니다. 이게 없으면 null 체크 분기를 매번 써야 하고, 빠뜨리면 NullPointerException이 납니다.
💼 실무·코딩테스트에서는Map은 코딩테스트에서 가장 많이 쓰는 자료구조입니다 — 개수 세기, 그룹 짓기, 존재 확인이 전부 Map으로 풀립니다. 코딩테스트 문제 "완주하지 못한 선수"가 정확히 이 패턴입니다. getOrDefault와 computeIfAbsent를 손에 익혀 두면 코드가 훨씬 짧아집니다 — computeIfAbsent(키, k -> new ArrayList<>())는 키가 없을 때만 새 값을 만들어 넣고 그 값을 돌려주므로, 그룹 짓기에서 map.computeIfAbsent(카테고리, k -> new ArrayList<>()).add(상품)처럼 한 줄로 씁니다.
TIPMap의 키로 내가 만든 클래스를 쓰려면 반드시 hashCode()와 equals()를 오버라이드해야 합니다. 안 그러면 같은 내용의 객체로 get해도 null이 나옵니다.
05
예외 처리 — try · catch · finally
Exceptiontry-catchfinallymulti catch
한 줄 요약실행 중 발생하는 오류를 프로그램이 스스로 처리하는 것이 예외 처리이며, try 블록에서 오류가 나면 catch가 받아 처리하고 finally는 오류 여부와 관계없이 항상 실행된다.
쉽게 말하면try-catch는 서커스의 안전망이에요. 곡예(try)를 하다 떨어지면 그물(catch)이 받아 주고, 공연이 성공하든 실패하든 조명을 끄는 일(finally)은 반드시 합니다. 안전망이 없으면 프로그램이 그 자리에서 멈춰 버립니다.
예외란 무엇인가
예외(Exception)는 프로그램이 스스로 처리할 수 있는 오류입니다. 문법을 틀리면 컴파일 단계에서 잡히니 고치면 되지만, 정수를 0으로 나누거나 배열 크기보다 큰 인덱스를 참조하는 것처럼 실행 중에 생기는 문제는 미리 수정할 수 없습니다. 이런 상황을 예외 처리로 대비합니다.
try ~ catch ~ finally의 흐름
try에는 에러가 발생할 수 있는 코드를, catch에는 에러를 처리할 코드를 씁니다. finally는 에러 여부와 관계없이 무조건 실행되므로, 파일이나 네트워크 연결을 닫는 것처럼 반드시 해야 하는 뒷정리에 씁니다.
catch 블록 여러 개 — 하위 예외를 먼저
catch 블록은 여러 개 쓸 수 있는데, 상위(포괄적) 예외를 나중에 써야 합니다. catch (Exception e)를 맨 위에 두면 모든 예외가 거기서 걸려 아래 catch는 도달할 수 없게 되어 컴파일 에러가 납니다.
Multi catch — catch (A | B e) (Java 7+)
처리 방법이 같은 예외 여러 개를 catch (A | B e)처럼 한 catch로 묶을 수 있습니다. 이때 A와 B는 서로 상속 관계가 아니어야 합니다(상속 관계면 상위 하나만 쓰면 되므로 컴파일 에러).
try-with-resources (Java 7+)
try (자원 선언) { } 형태로 쓰면 블록이 끝날 때 자동으로 close()가 호출됩니다. finally에서 일일이 닫던 코드를 없앨 수 있어, 파일·소켓 작업에서 표준적으로 쓰입니다(🌐 TCP 소켓 예제도 이 방식입니다).
JAVAExceptionTest.java
public class ExceptionTest {
public static void main(String[] args) {
int[] arr = {1, 2, 3};
try {
System.out.println(arr[5]); // ArrayIndexOutOfBoundsException 발생
System.out.println(10 / 0); // 여기는 실행되지 않는다
} catch (ArrayIndexOutOfBoundsException e) { // 하위(구체적) 예외를 먼저
System.out.println("배열 범위를 벗어났습니다: " + e.getMessage());
} catch (ArithmeticException e) {
System.out.println("0으로 나눌 수 없습니다.");
} catch (Exception e) { // 상위(포괄적) 예외는 마지막
System.out.println("그 밖의 예외: " + e);
} finally {
System.out.println("성공하든 실패하든 항상 실행됩니다.");
}
// Java 7+ : 여러 예외를 한 줄로 묶기
try {
String s = null;
s.length();
} catch (NullPointerException | ArithmeticException e) {
System.out.println("묶어서 처리: " + e.getClass().getSimpleName());
}
System.out.println("예외를 처리했으므로 프로그램은 계속 실행됩니다.");
}
}
핵심 정리
예외 = 프로그램이 스스로 처리할 수 있는 실행 중 오류.
try(위험한 코드) → catch(처리) → finally(무조건 실행) 순서.
catch는 하위(구체적) 예외를 먼저, 상위(Exception)를 나중에 써야 한다.
Java 7+ : catch (A | B e) 묶기, try (자원)로 자동 close 가능.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
finally 블록이 반드시 실행된다는 게 왜 중요한가?
자원 반납을 보장하기 때문입니다. 파일·DB 연결·소켓은 예외가 나든 안 나든 반드시 닫아야 하는데, try 안에서 예외가 터지면 그 뒤 코드는 실행되지 않죠. finally는 어떤 경로로 빠져나가든 실행되므로 close()를 두기에 안전한 자리입니다.
try-with-resources가 finally보다 나은 이유는?
close()를 자동으로 불러 주고, 예외가 겹칠 때도 안전하게 처리하기 때문입니다. finally에서 close()를 부르다 또 예외가 나면 원래 예외가 덮여 사라지는 문제가 있는데, try-with-resources는 이를 suppressed 예외로 보존합니다.
💼 실무·코딩테스트에서는실무에서 자원 누수는 서서히 서버를 죽이는 대표적 버그입니다. DB 커넥션을 안 닫으면 풀이 고갈되어 어느 순간 전체 요청이 멈추죠. 그래서 자원을 다루는 코드는 무조건 try-with-resources가 현대 자바의 기본입니다.
TIPcatch (Exception e) { }처럼 아무것도 안 하고 삼키는 코드는 최악입니다. 문제가 있었는데도 조용히 넘어가 원인 파악이 불가능해집니다. 최소한 e.printStackTrace()나 로그를 남기세요.
06
예외 계층과 사용자 정의 예외 (throw · throws)
ThrowableCheckedRuntimethrowthrows
한 줄 요약Throwable 아래에 Error(복구 불가)와 Exception이 있고 Exception은 반드시 처리해야 하는 Checked와 처리하지 않아도 컴파일되는 RuntimeException으로 나뉘며, Exception을 상속해 나만의 예외를 만들 수 있다.
쉽게 말하면Checked 예외는 "우산 챙겼는지 확인받아야 나갈 수 있는 것"이고 Runtime 예외는 "안 챙겨도 나갈 수는 있지만 비 오면 젖는 것"이에요. 파일·네트워크·DB처럼 내 잘못이 아니어도 실패할 수 있는 일은 컴파일러가 강제로 대비시키고, null 참조나 배열 범위 초과처럼 코드를 잘 짜면 안 나는 것은 강제하지 않습니다.
예외 계층 구조
최상위는 java.lang.Object → Throwable이고, 그 아래에 Error와 Exception이 있습니다. Error(VirtualMachineError, AssertionError, ThreadDeath 등)는 JVM 수준의 심각한 문제로 프로그램이 복구할 수 없어 처리 대상이 아닙니다. Exception은 다시 Checked Exception(IOException, InterruptedException 등)과 RuntimeException(NullPointerException, ClassCastException, IllegalArgumentException, IndexOutOfBoundsException, NumberFormatException 등)으로 나뉩니다.
Checked Exception이 자주 나오는 곳
java.io(IOException), java.net(SocketException 등 — IOException의 하위), java.sql(SQLException) 패키지에는 Checked Exception을 던지는 메서드가 많아서, 이런 메서드를 부르는 코드는 반드시 예외 처리를 해야 컴파일됩니다(패키지 전체가 Checked인 것은 아니고, 예외 클래스가 RuntimeException을 상속하지 않으면 Checked입니다). 파일이 없을 수도, 네트워크가 끊길 수도, DB가 응답하지 않을 수도 있어서 외부 환경에 의존하는 작업이기 때문입니다. 그래서 IO나 소켓 코드에는 항상 try-catch나 throws가 따라붙습니다.
throw vs throws
throw는 예외를 직접 발생시키는 명령입니다(throw new TestException("0으로 나누지 마세요");). throws는 메서드 선언부에 붙여 "이 메서드는 이런 예외를 던질 수 있으니 호출하는 쪽에서 처리하라"고 알리는 선언입니다. 즉 throw는 던지는 행위, throws는 던질 수 있다는 예고입니다.
사용자 정의 예외
Exception(또는 RuntimeException)을 상속해 만듭니다. 생성자에서 super(message)로 메시지를 넘기면 getMessage()로 꺼낼 수 있습니다. 표준 예외 대신 도메인에 맞는 이름의 예외를 만들면 에러 메시지만 봐도 무슨 상황인지 알 수 있어 유지보수가 쉬워집니다.
JAVATestException.java + UsingEx.java
// ---- 사용자 정의 예외: Exception을 상속 ----
class TestException extends Exception {
public TestException(String message) {
super(message); // 부모(Exception)에 메시지 전달
}
public TestException() {
this("0으로 나누지 마세요"); // 기본 메시지를 가진 생성자
}
}
public class UsingEx {
// throws : "이 메서드는 TestException을 던질 수 있다"는 예고
public static int sub(int a, int b) throws TestException {
if (b == 0) {
throw new TestException("0으로 나누지 마세요"); // throw : 실제로 던진다
}
return a / b;
}
public static void main(String[] args) {
try {
System.out.println(sub(10, 2)); // 5
System.out.println(sub(10, 0)); // 여기서 예외 발생
} catch (TestException e) {
System.out.println("사용자 예외 처리: " + e.getMessage());
}
// ---- Checked vs Runtime 차이 ----
// Checked: 처리하지 않으면 컴파일 자체가 안 된다
try {
new java.io.FileReader("없는파일.txt"); // IOException(Checked)
} catch (java.io.IOException e) {
System.out.println("파일 예외: " + e.getMessage());
}
// Runtime: 처리하지 않아도 컴파일은 된다 (실행 중에 터질 뿐)
String s = null;
try {
s.length(); // NullPointerException
} catch (RuntimeException e) {
System.out.println("런타임 예외: " + e.getClass().getSimpleName());
}
}
}
Checked Exception이 자주 나오는 곳: java.io(IOException), java.net(SocketException), java.sql(SQLException).
throw는 예외를 던지는 행위, throws는 던질 수 있다는 선언.
사용자 정의 예외는 Exception을 상속하고 super(message)로 메시지를 전달한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
Checked와 Unchecked 예외를 나눈 기준은 무엇인가?
"호출하는 쪽이 대비할 수 있는가"입니다. 파일이 없을 수 있는 건 예상 가능한 상황이라 Checked(IOException)로 두어 처리를 강제합니다. NPE나 배열 범위 초과는 프로그래머의 실수라 강제해 봐야 소용없으니 Unchecked입니다.
throw와 throws의 차이는?
throw는 예외를 실제로 던지는 실행문이고, throws는 "이 메서드는 이런 예외를 낼 수 있다"고 알리는 선언입니다. 하나는 행동, 하나는 표지판이라고 보면 됩니다.
💼 실무·코딩테스트에서는예외를 삼키는 catch (Exception e) {}는 피하고(05 TIP 참고), 그 자리에서 처리할 수 없다면 로그를 남기고 다시 던지는 것이 기본입니다. 또 비즈니스 규칙 위반은 사용자 정의 예외로 만들어 의미를 드러내는 것이 좋은 설계입니다.
TIPNumberFormatException은 Integer.parseInt("abc")처럼 숫자가 아닌 문자열을 변환할 때 나는 RuntimeException입니다. 사용자 입력을 숫자로 바꿀 때는 항상 이 예외를 염두에 두세요.
07
람다식(Lambda)
->익명함수함수형 인터페이스JDK 8
한 줄 요약람다식은 JDK 8부터 지원되는 익명 함수 표현식으로, 추상 메서드가 하나뿐인 인터페이스(함수형 인터페이스)를 (매개변수) -> 실행문 형태로 간결하게 구현한다.
쉽게 말하면람다식은 줄임말이에요. 익명 클래스로 5줄 쓰던 것을 "이 입력을 받아서 이걸 해라"는 한 줄로 줄입니다. 포장지(클래스 선언, 메서드 이름, @Override)를 다 벗겨내고 알맹이(로직)만 남기는 겁니다.
익명 클래스 → 람다식
인터페이스의 추상 메서드가 하나뿐일 때, 익명 클래스로 구현하던 것을 람다식으로 줄일 수 있습니다. new MyLambda() { @Override public void show() { ... } }가 () -> ...가 되는 식입니다. 어떤 메서드를 구현하는지 명확하기 때문에(하나뿐이니) 이름을 생략해도 컴파일러가 알아냅니다.
문법 형태
매개변수 없음: () -> System.out.println("Hello"). 1개: (x) -> System.out.println(x)(괄호 생략 가능). 여러 개: (x, y) -> x + y. 실행문이 한 줄이면 중괄호 생략 가능, 반환값이 있고 한 줄이면 return도 생략 가능합니다. 여러 줄이면 중괄호와 return을 모두 써야 합니다.
함수형 인터페이스
추상 메서드가 정확히 하나인 인터페이스를 함수형 인터페이스라 하며, 람다식으로 구현할 수 있습니다. @FunctionalInterface 어노테이션을 붙이면 컴파일러가 "추상 메서드가 정말 하나인지" 검사해 줍니다. 자바가 기본 제공하는 것으로 Runnable, Comparator, Function, Predicate, Consumer, Supplier 등이 있습니다.
바깥 지역변수는 effectively final만
람다 안에서 바깥 메서드의 지역변수를 읽을 수는 있지만, 그 변수는 final이거나 선언 후 한 번도 값이 바뀌지 않는 변수(effectively final)여야 합니다. int count = 0; list.forEach(n -> count++);처럼 람다 안에서(또는 람다 밖에서라도) 값을 바꾸면 컴파일 에러가 납니다. 익명 클래스도 같은 규칙입니다. (필드는 이 제약을 받지 않습니다.)
장점과 단점
장점: 불필요한 익명 클래스 제거로 코드 간결화, 핵심 로직만 남아 가독성 향상, Stream API·병렬 처리와 결합 가능, 내부 최적화로 성능 향상 가능. 단점: 복잡한 로직에는 부적합(간단한 표현식에 적합), 익명 함수라 디버깅 시 추적이 어렵고, 재사용성이 낮아 한 번 쓰고 끝나는 경우가 많습니다.
JAVALambdaTest.java
import java.util.*;
@FunctionalInterface // 추상 메서드가 하나뿐임을 컴파일러가 검사
interface MyLambda { void show(); }
@FunctionalInterface
interface Calc { int apply(int x, int y); }
public class LambdaTest {
public static void main(String[] args) {
// ---- 익명 클래스 방식 (JDK 7 이하) ----
MyLambda obj1 = new MyLambda() {
@Override
public void show() { System.out.println("Hello, Java! (익명 클래스)"); }
};
obj1.show();
// ---- 람다식 방식 (JDK 8+) — 같은 일을 한 줄로 ----
MyLambda obj2 = () -> System.out.println("Hello, Java! (람다식)");
obj2.show();
// ---- 매개변수·반환값이 있는 경우 ----
Calc add = (x, y) -> x + y; // 한 줄이면 중괄호와 return 생략
Calc mul = (x, y) -> { // 여러 줄이면 둘 다 필요
int r = x * y;
return r;
};
System.out.println(add.apply(3, 4) + " " + mul.apply(3, 4)); // 7 12
// ---- 표준 함수형 인터페이스 활용 ----
List<String> names = new ArrayList<>(Arrays.asList("John", "Anna", "Bob"));
names.forEach(n -> System.out.print(n + " ")); // Consumer
System.out.println();
names.sort((a, b) -> a.length() - b.length()); // Comparator (길이순 정렬)
System.out.println(names);
names.removeIf(n -> n.startsWith("A")); // Predicate
System.out.println(names);
new Thread(() -> System.out.println("스레드도 람다로")).start(); // Runnable
}
}
핵심 정리
람다식 = JDK 8+ 익명 함수 표현식, 추상 메서드가 하나인 인터페이스에만 쓸 수 있다.
(매개변수) -> 실행문, 한 줄이면 중괄호와 return 생략 가능.
@FunctionalInterface는 추상 메서드가 하나인지 컴파일러가 검사하게 한다.
간결하지만 디버깅이 어렵고 재사용성이 낮아, 짧은 로직에 쓰는 것이 적합하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
람다가 익명 클래스보다 나은 점은 코드 길이뿐인가?
길이도 있지만, 의도가 드러난다는 게 더 큽니다. 익명 클래스는 "객체를 만든다"는 문법에 진짜 하려는 동작이 파묻히지만, 람다는 동작 자체만 남습니다. 또 익명 클래스와 달리 별도 클래스 파일을 만들지 않습니다.
람다를 쓰려면 인터페이스에 어떤 조건이 필요한가?
추상 메서드가 딱 하나여야 합니다(함수형 인터페이스). 두 개면 람다가 어느 것을 구현하는지 알 수 없기 때문이죠. @FunctionalInterface를 붙이면 컴파일러가 이 조건을 검사해 줍니다.
💼 실무·코딩테스트에서는람다는 Stream·Optional·비동기 코드 어디에나 등장하므로 현대 자바에서 필수입니다. 실무에서는 Comparator를 람다로 쓰는 경우가 가장 많습니다 — list.sort((a,b) -> a.getAge() - b.getAge())처럼요. 다만 람다가 길어지면 메서드로 빼는 것이 읽기에 낫습니다.
TIPSystem.out::println처럼 메서드 참조(::)를 쓰면 람다식을 더 줄일 수 있습니다. n -> System.out.println(n)과 같은 뜻이며, Stream에서 자주 보게 됩니다.
08
Stream API — 중간 연산과 최종 연산
stream()filtermapcollectLazy
한 줄 요약Stream은 컬렉션·배열의 요소를 함수형 스타일로 반복·변환·필터링하는 JDK 8 기능으로, 스트림 생성 → 중간 연산(filter·map·sorted) → 최종 연산(forEach·collect·count) 파이프라인으로 동작하며 원본을 변경하지 않는다.
쉽게 말하면Stream은 공장의 컨베이어 벨트예요. 재료(컬렉션)를 벨트에 올리고, 중간에 불량품 걸러내기(filter)·모양 바꾸기(map)·정렬(sorted) 같은 공정을 거쳐, 마지막에 포장(collect)하거나 세어보는(count) 겁니다. 중요한 건 마지막 공정 버튼을 누르기 전까지는 벨트가 아예 돌지 않는다는 것(Lazy Evaluation)입니다.
Stream의 특징
① 컬렉션·배열 등의 데이터를 읽기 전용으로 처리합니다(원본 데이터 변경 X). ② 함수형 스타일이라 코드 가독성이 좋습니다. ③ 연산을 조합해 필터링·정렬·변환을 이어 붙일 수 있습니다. ④ 병렬 처리(parallelStream())를 지원해 성능을 높일 수 있습니다.
스트림 생성 방법
컬렉션에서 list.stream(), 배열에서 Arrays.stream(arr), 값 나열로 Stream.of("A","B","C"). 그 밖에 숫자 전용 IntStream·LongStream·DoubleStream, 파일에서 생성, iterate·generate로 무한 스트림을 만드는 방법도 있습니다.
최종 연산을 실행하면 스트림이 소모되어 다시 사용할 수 없습니다. forEach()(각 요소 처리) · collect()(리스트/셋 등으로 변환) · count()(개수) · reduce()(하나의 값으로 축약, 합계 등) · findFirst()(첫 요소) · allMatch()(모두 조건 만족?) · anyMatch(). 이 중 reduce()(초깃값 없이 쓸 때)와 findFirst()는 스트림이 비어 있으면 결과가 없을 수도 있어서 값을 바로 주지 않고 Optional이라는 상자에 감싸 돌려줍니다. 아래 코드의 .get()은 그 상자에서 값을 꺼내는 호출이며, 상자가 비어 있으면 NoSuchElementException이 나므로 실무에서는 orElse(기본값)을 더 많이 씁니다. 실무에서는 DB 조회 결과 후처리, JSON/CSV 파싱 후 가공, 목록 조건 검색, API 응답 정제에 많이 씁니다.
JAVAStreamExample.java
import java.util.*;
import java.util.stream.*;
public class StreamExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("John", "Anna", "Bob", "Alice", "Anna");
// ---- 기본 파이프라인: 생성 → 중간 연산 → 최종 연산 ----
names.stream() // 1. 스트림 생성
.filter(n -> n.startsWith("A")) // 2. 중간 연산 (필터링)
.forEach(System.out::println); // 3. 최종 연산 (출력)
// ---- 중간 연산 조합 ----
List<String> result = names.stream()
.distinct() // 중복 제거 (Anna 하나만)
.filter(n -> n.length() > 3) // 길이 4 이상
.map(String::toUpperCase) // 대문자로 변환
.sorted() // 정렬
.collect(Collectors.toList()); // 리스트로 수집
System.out.println(result); // [ALICE, ANNA, JOHN]
// ---- 숫자 처리 ----
List<Integer> nums = Arrays.asList(5, 3, 9, 1, 7, 9);
System.out.println(nums.stream().count()); // 6
System.out.println(nums.stream().filter(n -> n > 5).count()); // 3
System.out.println(nums.stream().reduce((a, b) -> a + b).get()); // 34 (합계, Optional에서 꺼냄)
System.out.println(nums.stream().allMatch(n -> n > 0)); // true
System.out.println(nums.stream().sorted().findFirst().get()); // 1 (최솟값)
// IntStream: 숫자 전용 스트림은 sum()·average()를 바로 제공
System.out.println(IntStream.rangeClosed(1, 10).sum()); // 55
// ---- 원본은 바뀌지 않는다 ----
System.out.println(names); // [John, Anna, Bob, Alice, Anna] 그대로
// ---- 최종 연산 후 재사용은 불가 ----
Stream<String> s = names.stream();
s.count();
// s.count(); // IllegalStateException: stream has already been operated upon
}
}
핵심 정리
파이프라인: 생성(stream()) → 중간 연산(filter·map·sorted·distinct·limit) → 최종 연산(forEach·collect·count·reduce).
중간 연산은 Lazy — 최종 연산이 호출될 때 비로소 실행된다.
최종 연산을 실행하면 스트림이 소모되어 재사용할 수 없다.
원본 데이터는 변경되지 않으며, parallelStream()으로 병렬 처리도 가능하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
Stream의 중간 연산이 '지연 실행'된다는 게 무슨 뜻인가?
최종 연산이 호출되기 전까지 아무것도 실행되지 않는다는 뜻입니다. filter·map만 써 두면 계획만 세운 상태이고, collect나 forEach가 붙는 순간 한 번에 흐릅니다. 덕분에 불필요한 계산을 건너뛸 수 있습니다.
Stream이 for문보다 항상 좋은가?
아닙니다. 가독성은 대체로 좋지만 단순 반복은 for문이 더 빠르고, 디버깅도 for문이 쉽습니다. 중간에 break가 필요한 로직은 Stream으로 표현하기 어색하죠. "데이터를 걸러 변환해 모으는" 흐름일 때 Stream이 빛납니다.
💼 실무·코딩테스트에서는실무 코드에서 Stream은 이제 기본 문법 수준으로 쓰입니다. 다만 지나치게 긴 스트림 체인은 오히려 읽기 어렵다는 지적을 받습니다. 면접에서는 지연 평가와 map vs flatMap 차이를 자주 묻습니다(map은 요소 하나를 값 하나로 바꾸고, flatMap은 요소 하나를 스트림으로 바꾼 뒤 그 스트림들을 한 줄로 펼쳐 합칩니다 — 예: List<List<String>> → Stream<String>).
TIP반복문으로 20줄 걸리던 "필터링 → 변환 → 정렬 → 수집"이 Stream으로는 4줄이 됩니다. 다만 디버깅이 어렵고 단순 반복에는 오히려 느릴 수 있으므로, 데이터 가공이 여러 단계일 때 쓰는 것이 좋습니다.
09
입출력(IO) 개요 — 스트림 · 노드 · 필터
StreamNodeFilterByteChar
한 줄 요약자바의 IO 스트림은 한 방향으로만 흐르는 데이터 통로로, 1byte 단위 처리(InputStream/OutputStream)와 2byte 문자 단위 처리(Reader/Writer)로 나뉘고, 실제 소스에 직접 연결되면 노드 스트림, 다른 스트림을 감싸 기능을 더하면 필터 스트림이다.
쉽게 말하면스트림은 물이 흐르는 파이프예요. 파이프는 한 방향으로만 흐르고 서로 섞이지 않으며, 읽는 파이프(Input/Read)와 쓰는 파이프(Output/Write)가 따로 있습니다. 노드 스트림은 수도꼭지에 직접 연결된 파이프이고, 필터 스트림은 그 파이프에 끼우는 정수 필터라서 혼자서는 아무 데도 연결되지 않습니다.
스트림의 성질
※ 여기서 말하는 IO 스트림(java.io)은 util-08의 Stream API(java.util.stream, filter·map 등)와 이름만 같고 전혀 다른 기능입니다.
① 연속적인 데이터의 흐름이며 기본 전송 단위는 byte입니다. 문자 스트림(Reader/Writer)은 그 byte를 인코딩(UTF-8 등)에 맞게 해석해 char 단위로 다룹니다. ② 단방향으로 흐릅니다(읽기 전용 또는 쓰기 전용). ③ 스트림끼리는 섞이지 않습니다. ④ 버퍼를 가질 수 있습니다 — 데이터를 버퍼에 모았다가 한 번에 읽고 써서 속도를 높이고, readLine()처럼 한 줄 단위로 읽는 기능도 버퍼가 있는 스트림(BufferedReader)이 제공합니다. ⑤ FIFO(First In First Out) 구조입니다.
이름만 봐도 방향과 단위를 알 수 있다
클래스 이름에 IN·READ가 들어가면 읽기 전용, OUT·WRITE가 들어가면 쓰기 전용입니다. 그리고 1byte 단위 처리는 InputStream/OutputStream(이미지·동영상 등 바이너리 데이터에 적합), 2byte(char) 단위 처리는 Reader/Writer(문자열 처리에 적합)입니다. 이 두 축(방향 × 단위)만 알면 수십 개의 IO 클래스 이름이 규칙적으로 읽힙니다.
노드 스트림 vs 필터 스트림
노드(Node) 스트림은 실제 데이터 소스(source)나 목적지(sink)에 직접 연결되어 단순히 읽고 씁니다 — FileInputStream, FileOutputStream, ByteArrayInputStream 등. 이름에 Byte·Char·String·File·Piped·Socket·System.in이 들어가면 노드이고, 그 외는 전부 필터라고 보면 됩니다. 필터(Filter) 스트림은 다른 스트림을 감싸서 기능을 추가합니다 — BufferedInputStream(버퍼링), DataInputStream(기본타입 단위 읽기), ObjectInputStream(객체 직렬화).
버퍼가 필요한 이유
1byte씩 파일에 접근하면 하드디스크에 수천 번 접근하게 되어 매우 느립니다. 버퍼는 데이터를 임시로 모아두는 공간으로, 어느 정도 쌓였을 때 한 번에 처리해 성능을 크게 높입니다. 그래서 실무 코드는 거의 항상 BufferedReader·BufferedWriter로 감싸서 씁니다.
JAVAIoOverview.java
import java.io.*;
public class IoOverview {
public static void main(String[] args) throws IOException {
// ---- 노드 스트림: 파일에 직접 연결 (1byte 단위) ----
FileOutputStream fos = new FileOutputStream("test.txt");
fos.write("Hello".getBytes());
fos.close();
// ---- 필터 스트림: 노드 스트림을 감싸서 기능 추가 ----
// FileReader(노드) → BufferedReader(필터) 로 감싸면 readLine() 사용 가능
FileReader fr = new FileReader("test.txt"); // 2byte(char) 단위 노드
BufferedReader br = new BufferedReader(fr); // 버퍼 + readLine() 추가
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
br.close(); // 바깥(감싼 쪽, 마지막에 만든 것)을 닫으면 안쪽 fr도 자동으로 닫힌다
// ---- 키보드 입력도 같은 구조 ----
// System.in(노드, byte) → InputStreamReader(byte→char 변환) → BufferedReader(필터)
BufferedReader keyboard =
new BufferedReader(new InputStreamReader(System.in));
System.out.print("입력하세요: ");
System.out.println("입력값: " + keyboard.readLine());
}
}
핵심 정리
스트림은 단방향·FIFO이며 기본 전송 단위는 byte(문자 스트림은 인코딩을 해석해 char 단위), 버퍼를 쓰면 한 번에 모아 처리해 빨라진다.
1byte 단위 = InputStream/OutputStream(바이너리) / 2byte 단위 = Reader/Writer(문자).
이름에 IN·READ = 읽기, OUT·WRITE = 쓰기.
노드 스트림(소스에 직접 연결) vs 필터 스트림(다른 스트림을 감싸 기능 추가).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
스트림(Stream)을 '흐름'이라고 부르는 이유는?
데이터를 한 번에 다 읽는 게 아니라 바이트 단위로 흘려보내기 때문입니다. 그래서 파일이 아무리 커도 메모리를 조금만 쓰며 처리할 수 있습니다. 또 단방향이라 입력용과 출력용이 따로 있습니다.
노드 스트림과 필터 스트림의 관계를 설명해보세요.
노드 스트림이 실제 대상(파일·네트워크)에 직접 연결되고, 필터 스트림은 그 위에 덧씌워 기능을 더합니다(버퍼링·문자 변환 등). new BufferedReader(new FileReader(f))처럼 감싸는 구조가 되는 이유입니다.
💼 실무·코딩테스트에서는IO는 데코레이터 패턴의 교과서적 사례라 설계 공부에도 좋습니다. 실무에서는 버퍼링 여부가 성능을 수십 배 바꾸므로, 파일을 다룰 때 Buffered~로 감싸는 것이 기본입니다. 요즘은 Files.readAllLines() 같은 편의 메서드도 많이 씁니다.
TIPnew BufferedReader(new InputStreamReader(System.in))이라는 긴 코드가 왜 이렇게 생겼는지 이제 읽힙니다 — System.in(byte 노드)을 InputStreamReader(byte→char 변환)로 바꾸고 BufferedReader(버퍼 + readLine)로 감싼 3단 구조입니다.
한 줄 요약read()는 데이터를 읽어 int로 반환하고(음수면 더 읽을 데이터 없음) write()는 데이터를 내보내며, 모든 입출력은 작업 후 반드시 close()해야 하고 그 위치는 finally(또는 try-with-resources)여야 한다.
쉽게 말하면파일을 여는 건 수도꼭지를 트는 것이라, 다 쓰고 잠그지 않으면(close) 물이 계속 흐르고 다른 사람이 그 수도를 못 씁니다(파일이 "사용 중"이 되어 편집·삭제 불가). 그리고 중간에 사고가 나도 잠가야 하니까 잠그는 코드는 반드시 finally에 둡니다.
읽기: read()의 3가지 형태와 반환값
int read()는 1byte씩 읽고, int read(byte[] b)는 배열 길이만큼 읽으며, int read(byte[] b, int index, int length)는 원하는 위치와 길이만큼 읽습니다. 반환 타입이 int인 이유는 "데이터"와 "끝"을 구분하기 위해서입니다. 읽은 1byte는 0~255 범위의 int로 돌려주고, 더 읽을 데이터가 없으면 -1을 돌려줍니다. 만약 반환 타입이 byte였다면 실제 데이터 0xFF(byte로는 -1)와 "끝(-1)"을 구별할 수 없었을 것입니다. read()는 읽은 값을, read(byte[])는 읽은 데이터 수를 반환합니다. read()는 추상 메서드인데, 읽어들이는 대상(파일·네트워크·메모리)에 맞게 각각 구현하기 위해서입니다.
쓰기: write()와 flush()
write(int)·write(byte[])·write(byte[], index, length)가 있고, Writer 계열에는 write(String)도 있습니다. flush()는 버퍼에 남아 있는 데이터를 강제로 내보냅니다. 버퍼를 쓰는 스트림(FileWriter·BufferedWriter 등)은 버퍼가 가득 차거나 close()할 때만 저절로 비워지므로, 그 전에 바로 내보내야 하면(예: 네트워크로 즉시 보낼 때) flush()를 직접 부릅니다. close()를 호출하면 flush도 함께 일어납니다 — 그래서 close를 안 하면 마지막 데이터가 파일에 안 써지는 일이 생깁니다.
Reader에는 문자열 읽기가 없다
Reader 클래스는 문자 단위(2byte)로 읽으며 문자열을 통째로 읽는 메서드가 없습니다. 한 줄씩 읽으려면 하위 클래스인 BufferedReader로 감싸 readLine()을 써야 합니다. Writer 쪽은 write(String)이 있어 문자열을 바로 쓸 수 있습니다.
close()를 finally에 두는 이유
파일·네트워크 작업은 하드웨어 고장이나 연결 끊김 등 예상치 못한 상황이 생길 수 있어서 IO는 무조건 예외 처리하도록 설계되어 있습니다. close()를 try 블록 안에 쓰면 그 전에 예외가 나는 순간 실행되지 않으므로, 예외 발생 여부와 무관하게 실행되는 finally에 써야 합니다. 그리고 마지막에 만든(가장 바깥) 스트림부터 먼저 닫습니다 — 필터 스트림은 바깥을 닫으면 감싸고 있는 안쪽 스트림도 자동으로 닫히므로, 보통은 가장 바깥 하나만 닫으면 됩니다. Java 7+의 try-with-resources를 쓰면 이 과정이 자동화됩니다.
JAVAFileIoTest.java
import java.io.*;
public class FileIoTest {
public static void main(String[] args) {
// ---- 쓰기: Writer 계열은 write(String)이 있다 ----
Writer writer = null;
try {
writer = new FileWriter("file.txt");
writer.write("문자열을 출력합니다.\n두 번째 줄입니다.");
} catch (IOException e) {
e.printStackTrace();
} finally {
// close는 반드시 finally에서 — 위에서 예외가 나도 실행되어야 한다
if (writer != null) {
try { writer.close(); } catch (IOException e) { }
}
}
// ---- 읽기: Reader에는 문자열 읽기가 없으므로 BufferedReader로 감싼다 ----
try (Reader read = new FileReader("file.txt"); // try-with-resources
BufferedReader br = new BufferedReader(read)) { // 자동으로 close된다
String line;
while ((line = br.readLine()) != null) { // null이면 끝
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
// ---- 1byte 단위 읽기: 데이터는 0~255, 더 읽을 데이터가 없으면 -1 ----
// ※ file.txt에 한글이 있으므로 이 부분은 글자가 깨져 출력된다(의도한 비교).
// 한글 한 글자는 여러 byte인데, 1byte씩 (char)로 바꾸기 때문이다 → 텍스트는 Reader로 읽는다.
try (InputStream in = new FileInputStream("file.txt")) {
int c;
while ((c = in.read()) != -1) { // -1 = EOF
System.out.print((char) c);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
핵심 정리
read()는 데이터를 0~255의 int로, 끝이면 -1을 돌려준다 — byte로 돌려주면 데이터 0xFF와 끝(-1)을 구별할 수 없어서 int를 쓴다.
flush()는 버퍼를 강제로 비우고, close()를 호출하면 자동 flush된다.
Reader에는 문자열 읽기가 없어 BufferedReader.readLine()을 쓴다.
close()는 finally(또는 try-with-resources)에서, 가장 바깥 스트림부터 닫는다(바깥을 닫으면 안쪽도 자동으로 닫힌다).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
InputStream 계열과 Reader 계열을 나눈 기준은?
바이트냐 문자냐입니다. InputStream은 1바이트 단위라 이미지·동영상 같은 이진 데이터에, Reader는 문자 단위라 텍스트에 씁니다. 한글은 여러 바이트라 바이트 스트림으로 읽으면 깨집니다.
read(byte[] b)의 반환값을 무시하면 왜 위험한가?
버퍼가 항상 가득 차지는 않기 때문입니다. 512바이트 버퍼에 실제로 10바이트만 들어올 수 있는데, 버퍼 전체를 처리하면 이전에 남아 있던 찌꺼기까지 함께 쓰게 됩니다. 반환값(실제로 읽은 개수)만큼만 써야 합니다.
💼 실무·코딩테스트에서는이 "읽은 만큼만 쓴다" 원칙은 네트워크 프로그래밍에서 특히 중요합니다. UDP 소켓 프로그래밍 수업 코드에서 응답 길이로 buffer.length를 그대로 써서 찌꺼기가 딸려 간 버그가 정확히 이것입니다(원본과 수정 과정은 19일차 수업 기록). 실무에서 데이터가 이상하게 붙어 오는 문제의 단골 원인입니다.
TIP"파일을 만들었는데 내용이 비어 있다"면 십중팔구 close()를 안 한 것입니다. 버퍼에만 쌓여 있고 실제 파일에는 안 쓰인 상태이기 때문입니다.
11
스레드(Thread) — 생성 · 동기화 · 생명주기
ThreadRunnablesynchronizedstart()wait/notify
한 줄 요약스레드는 프로세스 안에서 동시에 실행되는 작업 단위로 Runnable 구현 또는 Thread 상속으로 만들고 start()로 실행하며, 공유 객체를 여러 스레드가 건드릴 때는 synchronized로 동기화해야 한다.
쉽게 말하면프로세스가 식당 하나라면 스레드는 그 안의 직원들이에요. 직원이 여럿이면 주문·요리·서빙을 동시에 할 수 있지만, 같은 냄비(공유 객체)를 두 명이 동시에 건드리면 요리가 엉망이 됩니다. synchronized는 "이 냄비 쓰는 동안 아무도 손대지 마"라고 잠그는 것입니다.
프로세스 vs 스레드
멀티 프로세스는 애플리케이션 단위의 멀티태스킹이고, 멀티 스레드는 하나의 애플리케이션 안에서 여러 작업을 동시에 실행하는 것입니다. 스레드는 프로세스 내부의 작은 실행 단위로, 같은 메모리를 공유하기 때문에 가볍지만 그만큼 공유 자원 충돌에 주의해야 합니다.
생성 방법 2가지와 장단점
① Runnable 인터페이스 구현: Thread tr = new Thread(myRunnable); tr.start(); — 장점은 다른 클래스를 이미 상속받고 있어도 쓸 수 있고(자바는 단일 상속), 스레드와 작업을 분리해 재사용성·유지보수성이 좋다는 것. 단점이라 할 만한 것은 run() 안에서 getName() 같은 Thread 메서드를 바로 부를 수 없어 Thread.currentThread().getName()처럼 현재 스레드를 꺼내 써야 한다는 정도입니다(이렇게 하면 제어에 부족함은 없습니다). ② Thread 클래스 상속: Thread tr = new MyThread(); tr.start(); — 장점은 코드가 간결하고 run() 안에서 getName() 등 Thread 메서드를 바로 쓸 수 있다는 것, 단점은 다른 클래스를 상속할 수 없고 재사용성이 떨어진다는 것.
start()와 run()의 차이
run()을 직접 호출하면 그냥 메서드 호출이라 현재 스레드에서 순차 실행됩니다. 반드시 start()를 호출해야 JVM이 새 스레드를 만들어 그 안에서 run()을 실행합니다. 초보자가 가장 많이 하는 실수입니다.
동기화(synchronized)
여러 스레드가 같은 객체를 공유하면, 스레드1이 값을 저장하고 출력하기 전에 스레드2가 값을 바꿔 버려 둘 다 같은(잘못된) 값을 출력하는 문제가 생깁니다. synchronized 메서드(메서드 전체 잠금)나 synchronized 블록(특정 객체에 대한 잠금)을 쓰면, 한 스레드가 그 메서드·블록을 빠져나갈 때까지 다른 스레드는 그 영역을 실행할 수 없습니다. 잠금은 스레드가 끝날 때가 아니라 synchronized 영역을 벗어나는 순간(정상 종료든 예외든) 풀립니다.
생명주기와 주요 메서드
자바의 상태(Thread.State)는 NEW(생성) → RUNNABLE(실행 대기·실행) ⇄ BLOCKED / WAITING / TIMED_WAITING(일시정지) → TERMINATED(종료)로 오갑니다. 잠금을 기다리면 BLOCKED, wait()·join()이면 WAITING, sleep(ms)처럼 시간이 정해지면 TIMED_WAITING이고, 풀려나면 다시 RUNNABLE로 돌아갑니다. start()(시작) · run()(작업 정의) · join()(다른 스레드가 끝날 때까지 대기) · sleep()(일정 시간 정지) · interrupt()(인터럽트) · yield()(같은 우선순위에 양보) · setPriority()(우선순위) · setDaemon()(데몬 스레드) · currentThread()(현재 스레드 조회). 그리고 wait()(notify가 부를 때까지 정지) · notify()/notifyAll()(대기 중인 스레드를 깨움)은 반드시 synchronized 상태에서만 사용할 수 있습니다. wait()·notifyAll()을 실제로 쓰는 생산자-소비자 예제는 18일차 수업 기록에 있습니다.
JAVAThreadTest.java
// ---- 방법 ①: Runnable 구현 (다른 클래스를 상속 중이어도 가능) ----
class MyRun implements Runnable {
@Override
public void run() {
for (int i = 0; i < 3; i++) System.out.println("Runnable " + i);
}
}
// ---- 방법 ②: Thread 상속 ----
class MyThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 3; i++) System.out.println("Thread " + i);
}
}
// ---- 공유 객체 + 동기화 ----
class Calculator {
private int cal;
// synchronized: 한 스레드가 이 메서드를 빠져나갈 때까지 다른 스레드는 들어올 수 없다
public synchronized void setAndPrint(int value) {
this.cal = value;
try { Thread.sleep(100); } catch (InterruptedException e) { }
System.out.println(Thread.currentThread().getName() + " → " + cal);
}
}
public class ThreadTest {
public static void main(String[] args) throws InterruptedException {
new Thread(new MyRun()).start(); // ① Runnable
new MyThread().start(); // ② Thread 상속
new Thread(() -> System.out.println("람다로도 가능")).start(); // ③ 람다
// ---- 동기화 확인: 없으면 둘 다 같은 값을 출력하는 버그가 생긴다 ----
Calculator c = new Calculator();
Thread t1 = new Thread(() -> c.setAndPrint(5), "스레드1");
Thread t2 = new Thread(() -> c.setAndPrint(10), "스레드2");
t1.start(); t2.start();
t1.join(); t2.join(); // 두 스레드가 끝날 때까지 기다린다
System.out.println("메인 스레드 종료");
}
}
핵심 정리
스레드 생성: Runnable 구현(다른 클래스 상속 가능·재사용성, 권장) vs Thread 상속(간결, Thread 메서드를 바로 사용).
반드시 start()로 실행 — run()을 직접 부르면 새 스레드가 만들어지지 않는다.
공유 객체 접근은 synchronized 메서드/블록으로 보호한다.
wait()·notify()·notifyAll()은 synchronized 안에서만 사용 가능하다.
join()은 그 스레드가 끝날 때까지 현재 스레드를 대기시킨다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
동기화(synchronized)가 필요한 상황을 구체적으로 말해보세요.
여러 스레드가 같은 데이터를 동시에 수정할 때입니다. count++는 한 줄처럼 보이지만 읽기→더하기→쓰기 세 단계라, 중간에 다른 스레드가 끼어들면 증가가 사라집니다. 동기화는 이 구간을 한 번에 한 스레드만 지나가게 합니다.
Thread를 상속하는 것과 Runnable을 구현하는 것 중 무엇이 권장되나?
Runnable입니다. 자바는 클래스를 하나만 상속할 수 있어서, Thread를 상속하면 다른 클래스를 상속할 기회를 잃습니다. 또 Runnable은 "할 일"과 "실행 수단"을 분리해 스레드 풀에도 그대로 넘길 수 있습니다.
💼 실무·코딩테스트에서는실무에서는 스레드를 직접 만들기보다 ExecutorService(스레드 풀)를 씁니다 — 스레드 생성 비용이 크고 개수 관리가 필요하기 때문입니다. 면접에서는 동기화·데드락·volatile을 자주 묻고, 실무에서는 동시성 버그가 재현이 어려워 가장 골치 아픈 부류입니다.
TIP스레드는 🌐 멀티스레드 서버에서 바로 쓰입니다 — 클라이언트가 접속할 때마다 스레드를 하나씩 만들어 여러 명이 동시에 채팅할 수 있게 하는 구조입니다. 교안의 마지막 실습(채팅 프로그램)이 정확히 이 조합입니다.
🌐 4. 자바 네트워크 프로그래밍
네트워크·프로토콜·IP·포트 같은 기본 개념부터 java.net의 Socket·ServerSocket을 이용한 TCP/UDP 통신, 그리고 여러 클라이언트를 동시에 받는 멀티스레드 서버(채팅 프로그램)까지 정리합니다.
01
네트워크와 프로토콜 — TCP vs UDP
프로토콜TCPUDP연결지향
한 줄 요약TCP는 1:1 연결을 먼저 수립한 뒤 데이터를 주고받는 신뢰성 있는 프로토콜이고, UDP는 연결 없이 바로 보내는 대신 도착과 순서를 보장하지 않는 빠른 프로토콜이다.
쉽게 말하면TCP는 등기우편이에요. 받는 사람을 확인하고, 도착했는지 확인서까지 받습니다(느리지만 확실). UDP는 전단지 뿌리기라서 그냥 던지고 끝입니다 — 몇 장은 바람에 날아가도 신경 쓰지 않는 대신 훨씬 빠릅니다. 파일 전송·웹페이지는 등기, 실시간 영상·게임은 전단지가 어울립니다.
네트워크와 프로토콜
네트워크는 두 대 이상의 컴퓨터가 데이터를 공유하기 위해 연결된 구조로, 인터넷 같은 광범위한 것부터 LAN 같은 작은 것까지 있습니다. 프로토콜은 클라이언트와 서버가 데이터를 주고받기 위한 통신 규칙이며, 대표적으로 TCP와 UDP가 있습니다.
TCP (Transmission Control Protocol)
클라이언트와 서버 간 1:1 연결을 지향하며 신뢰할 수 있는 통신을 제공합니다. 양단에 연결을 수립한 뒤 데이터를 전송하고 연결을 종료하는 3단계로 동작합니다. 연결 수립은 3-way handshake라 부르는데, 클라이언트가 "연결하자(SYN)" → 서버가 "좋아, 너도 받을 준비 됐니?(SYN+ACK)" → 클라이언트가 "됐어(ACK)"처럼 세 번 신호를 주고받아 양쪽이 모두 준비됐음을 확인하는 과정입니다. 데이터의 도착과 순서가 보장되며, 일반적인 HTTP 통신도 TCP 방식을 따릅니다(단, 최신 HTTP/3는 UDP 위에서 동작하는 QUIC 프로토콜을 씁니다).
UDP (User Datagram Protocol)
1:1 또는 1:N 비연결을 지향하며 신뢰할 수 없는 통신을 제공합니다. TCP와 달리 연결 설정 과정이 없고 소켓 생성과 데이터 송수신 과정만 존재합니다. 신뢰성은 떨어지지만 연결 수립에 필요한 부하가 없어 더 빠릅니다. 실시간 스트리밍, 온라인 게임, DNS 조회 등에 쓰입니다.
자바에서의 대응
TCP는 Socket·ServerSocket(06번 카드), UDP는 DatagramSocket·DatagramPacket(07번 카드)으로 구현합니다. TCP는 스트림 기반이라 InputStream/OutputStream을 그대로 쓸 수 있고, UDP는 패킷 단위라 byte 배열을 직접 다룬다는 차이가 코드에 그대로 드러납니다.
핵심 정리
프로토콜 = 데이터를 주고받기 위한 통신 규칙.
TCP: 1:1 연결 지향, 신뢰성 O(도착·순서 보장), 연결 수립·종료 과정 필요, HTTP의 기반.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
TCP와 UDP 중 무엇을 쓸지 판단하는 기준은?
"손실을 감수할 수 있는가"입니다. 파일 전송·웹·채팅처럼 한 글자라도 빠지면 안 되면 TCP, 실시간 영상·게임처럼 늦게 오느니 빠지는 게 나으면 UDP입니다. TCP의 재전송은 정확한 대신 지연을 만듭니다.
TCP가 '연결 지향'이라는 말이 실제로 무엇을 뜻하나?
데이터를 보내기 전에 3-way handshake로 상대와 통로를 먼저 만든다는 뜻입니다. 이 통로가 있어서 순서 보장·재전송·흐름 제어가 가능하죠. UDP는 이 과정이 없어 바로 던지는 대신 아무것도 보장하지 않습니다.
💼 실무·코딩테스트에서는면접에서 TCP/UDP 비교는 거의 반드시 나옵니다. 특징 나열보다 "왜 그런 특징이 생기는가"(연결을 맺기 때문에 순서 보장이 가능하다)로 엮어 답하면 좋습니다. 실무에서는 HTTP가 TCP 위에서 동작한다는 것부터 출발합니다.
TIP"신뢰성"은 속도가 아니라 보장의 문제입니다. TCP도 충분히 빠르며, UDP를 쓰는 이유는 "조금 손실돼도 되니 지연이 없어야 하는" 상황(실시간 통화에서 0.5초 늦게 도착한 음성은 이미 쓸모없음) 때문입니다.
02
유니캐스팅 · 브로드캐스팅 · 멀티캐스팅
UnicastBroadcastMulticastMAC
한 줄 요약유니캐스팅은 특정 장치 하나와의 1:1 통신, 브로드캐스팅은 네트워크 내 모든 호스트에게 보내는 1:N 통신, 멀티캐스팅은 특정 그룹에게만 보내는 방식이다.
쉽게 말하면유니캐스팅은 한 사람에게 전화 걸기, 브로드캐스팅은 동네 방송으로 전체 안내, 멀티캐스팅은 단톡방에만 공지예요. 같은 내용을 100명에게 보낼 때 전화를 100번 거는 건 비효율적이고, 동네 방송은 관심 없는 사람까지 듣게 되니, 그 중간이 단톡방(멀티캐스팅)입니다.
유니캐스팅
목적지를 하나만 지정해 보내는 1:1 통신입니다. 인터넷에서는 보통 목적지 IP 주소 하나를 지정하는 IP 유니캐스트를 말하고, 같은 네트워크 안에서 실제로 전달될 때는 그 장치의 물리주소(MAC, 네트워크 카드 고유값)가 쓰입니다. 가장 일반적인 방식이지만, 같은 내용을 여러 사용자에게 보낼 때는 호스트마다 데이터를 반복 전송해야 하므로 비효율적입니다.
브로드캐스팅
자신이 속한 네트워크 내 모든 호스트에게 데이터를 전달하는 1:N 방식입니다. 수신지 주소에 브로드캐스트 주소를 사용합니다. TCP는 상대 하나와 1:1로 연결을 맺는 방식이라 브로드캐스트가 불가능하므로, 응용 프로그램에서 브로드캐스트를 할 때는 UDP를 씁니다. 내부의 모든 호스트에게 같은 데이터를 보낼 때는 효율적이지만, 필요 없는 호스트까지 트래픽을 받게 됩니다.
멀티캐스팅
특정 그룹에게만 데이터를 전달하는 방식입니다. 유니캐스팅은 같은 데이터를 여러 번 반복 전송해야 하고 브로드캐스팅은 원하지 않는 그룹에도 전달되는데, 멀티캐스팅은 그 둘의 단점을 모두 피합니다. IPv4에서 클래스 D(224.0.0.0 ~ 239.255.255.255)가 멀티캐스트용으로 예약되어 있습니다.
핵심 정리
유니캐스팅 = 목적지 하나(1:1, 보통 IP 주소 하나 지정), 다수 전송 시 반복이라 비효율.
브로드캐스팅 = 1:N, 네트워크 내 모든 호스트, 브로드캐스트 주소 사용(TCP는 1:1 연결이라 불가 → 응용에서는 UDP).
멀티캐스팅 = 특정 그룹에만 전송, 유니/브로드캐스팅의 단점을 보완.
IPv4 클래스 D(224~239)가 멀티캐스트 전용 대역이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
유니캐스트·브로드캐스트·멀티캐스트를 한 문장으로 구분해보세요.
유니캐스트는 1:1(특정 상대에게), 브로드캐스트는 1:전체(같은 네트워크의 모두에게), 멀티캐스트는 1:그룹(구독한 대상에게만)입니다. 대부분의 통신은 유니캐스트이고, 나머지는 특수한 목적에 씁니다.
브로드캐스트를 남발하면 왜 문제가 되나?
관심 없는 모든 장비가 패킷을 받아 처리해야 하기 때문입니다. 네트워크 대역폭과 각 장비의 CPU를 낭비하죠. 그래서 라우터는 기본적으로 브로드캐스트를 다른 망으로 넘기지 않습니다.
💼 실무·코딩테스트에서는실무에서 직접 다룰 일은 적지만, DHCP가 브로드캐스트로 IP를 받아 온다거나 ARP가 브로드캐스트로 MAC 주소를 찾는다는 것을 알면 네트워크 문제를 진단할 때 도움이 됩니다. 스트리밍 서비스의 멀티캐스트도 같은 원리입니다.
TIP자바에서 멀티캐스팅은 MulticastSocket(DatagramSocket의 하위 클래스)으로 구현하며, joinGroup()으로 그룹에 참여합니다. 채팅 프로그램에서 "전체 공지"를 구현할 때 생각해 볼 수 있는 선택지입니다.
03
IP 주소 — 체계 · 클래스 · 서브넷 · 공인/사설
IPv4IPv6서브넷마스크DHCP사설IP
한 줄 요약IP 주소는 네트워크에서 장치를 식별하는 고유 주소로 IPv4는 32비트(4옥텟), IPv6는 128비트이며, 주소는 네트워크 부분(NetID)과 호스트 부분(HostID)으로 나뉘고 그 경계를 정하는 것이 서브넷 마스크다.
쉽게 말하면IP 주소는 건물 주소예요. 앞부분(NetID)은 동네 이름, 뒷부분(HostID)은 그 동네의 몇 번째 집인지를 나타냅니다. 서브넷 마스크는 "어디까지가 동네 이름인가"를 알려 주는 자이고, 사설 IP는 아파트 내부의 동·호수라서 밖에서는 그 주소만으로 찾아올 수 없습니다.
IPv4와 IPv6
IPv4는 32비트 길이로 4개의 8비트 옥텟으로 구성되며 각 옥텟은 0~255의 값을 가집니다(예: 192.168.0.1). IPv6는 128비트로 16비트씩 8개 블록을 콜론(:)으로 구분합니다(예: 2001:0db8:85a3:0000:0000:8a2e:0370:7334). IPv4 주소 고갈 때문에 IPv6가 등장했습니다.
IPv4 클래스 5가지
네트워크 크기에 따라 A~E로 나뉩니다. A: 1.0.0.0 ~ 126.255.255.255(대규모, NetID 1옥텟). B: 128.0.0.0 ~ 191.255.255.255(중간 규모, NetID 2옥텟). C: 192.0.0.0 ~ 223.255.255.255(소규모, NetID 3옥텟). D: 224.0.0.0 ~ 239.255.255.255(멀티캐스트). E: 240.0.0.0 ~ 255.255.255.255(향후 사용을 위한 예약). A클래스가 1부터 126까지인 이유는 0.x.x.x는 "이 네트워크 자신"을 뜻하는 특수 용도이고, 127.x.x.x 대역 전체는 내 컴퓨터를 가리키는 루프백(아래 TIP의 127.0.0.1)으로 예약되어 있기 때문입니다. NetID에 쓰는 옥텟이 적을수록(A) 네트워크 개수는 적지만 네트워크 하나에 들어가는 호스트가 많고, 많을수록(C) 네트워크 개수는 많지만 호스트는 적습니다. 참고로 이 클래스 방식은 주소 낭비가 커서 지금은 192.168.0.0/24처럼 경계를 비트 수로 자유롭게 정하는 CIDR 방식으로 대체되었고, 클래스는 개념 이해용으로 배웁니다.
서브넷 마스크
IP 주소를 네트워크 부분과 호스트 부분으로 나누는 데 사용합니다. 예를 들어 서브넷 마스크 255.255.255.0은 네트워크 부분이 24비트, 호스트 부분이 8비트라는 뜻이며, 이 네트워크에는 최대 254대(0과 255는 예약)의 호스트가 들어갈 수 있습니다.
고정/동적 IP, 공인/사설 IP
고정 IP는 변하지 않는 주소로 서버나 네트워크 장비에 씁니다. 동적 IP는 필요에 따라 할당되며 DHCP(Dynamic Host Configuration Protocol) 서버가 관리합니다. 공인 IP는 인터넷에서 유일한 주소로 ISP(인터넷 서비스 제공 회사)가 할당하고, 사설 IP는 내부 네트워크에서만 쓰는 주소입니다(예: 192.168.0.0 ~ 192.168.255.255). 공유기(라우터)가 하나의 공인 IP를 받아 내부 기기들에게 사설 IP를 나눠 줍니다.
네트워크 구성 요소
LAN(Local Area Network)은 집·사무실 규모의 작은 네트워크, WAN(Wide Area Network)은 이를 넓게 연결한 네트워크입니다. 공유기(라우터)는 IP를 분배하고 네트워크를 분리하며, 스위치는 회선을 확장하는 역할을 합니다. 공유기 아래에 또 공유기를 두면 네트워크가 계층적으로 분리됩니다.
JAVAIpTest.java
import java.net.InetAddress;
import java.net.UnknownHostException;
public class IpTest {
public static void main(String[] args) throws UnknownHostException {
// 내 컴퓨터의 IP (보통 사설 IP가 나온다)
InetAddress local = InetAddress.getLocalHost();
System.out.println("호스트명 : " + local.getHostName());
System.out.println("IP 주소 : " + local.getHostAddress());
// 도메인 이름으로 공인 IP 조회 (DNS 조회)
InetAddress remote = InetAddress.getByName("www.google.com");
System.out.println("google IP: " + remote.getHostAddress());
// 한 도메인에 여러 IP가 매핑된 경우 전부 조회
for (InetAddress a : InetAddress.getAllByName("www.google.com")) {
System.out.println(" - " + a.getHostAddress());
}
}
}
서브넷 마스크가 NetID와 HostID의 경계를 정한다(255.255.255.0 = 네트워크 24비트).
공인 IP는 인터넷에서 유일, 사설 IP(192.168.x.x 등)는 내부 전용이며 DHCP가 동적 할당을 관리한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
사설 IP가 필요한 이유는?
IPv4 주소가 부족하기 때문입니다. 약 43억 개뿐이라 모든 기기에 공인 IP를 줄 수 없죠. 그래서 집·회사 내부에서만 통하는 주소를 따로 두고, 공유기가 하나의 공인 IP로 변환(NAT)해 내보냅니다.
서브넷 마스크가 하는 일을 한마디로 말해보세요.
IP 주소에서 어디까지가 네트워크이고 어디부터가 호스트인지 나누는 것입니다. 255.255.255.0이면 앞 3자리가 네트워크, 마지막이 호스트죠. 이 구분으로 같은 네트워크인지 판단해 라우터를 거칠지 말지를 결정합니다.
💼 실무·코딩테스트에서는실무에서 "서버에 접속이 안 된다"를 진단할 때 IP·서브넷·게이트웨이를 확인하는 것이 첫 단계입니다. 클라우드(AWS VPC 등)를 쓰면 서브넷을 직접 설계해야 하므로, 이 개념이 곧바로 실무 지식이 됩니다.
TIP소켓 예제에서 localhost(= 127.0.0.1)를 쓰는 이유는 내 컴퓨터 자신을 가리키는 루프백 주소이기 때문입니다(127로 시작하는 대역 전체가 루프백용이라 A클래스 범위에서 빠져 있습니다). 같은 PC에서 서버와 클라이언트를 동시에 띄워 테스트할 때 씁니다.
04
포트 번호와 OSI 7계층
Port80/443OSI 7 Layer패킷
한 줄 요약IP 주소가 호스트를 식별한다면 포트는 그 호스트 안의 특정 프로세스를 식별하는 논리적 주소로 0~65535 범위를 가지며, OSI 7계층은 통신 과정을 계층별로 나눠 각 계층이 헤더를 붙여가며 데이터를 감싸는 구조를 설명한다.
쉽게 말하면IP가 건물 주소라면 포트는 호실 번호예요. 같은 건물(서버)에 웹 서비스(80호), 보안 웹(443호), 파일 전송(21호)이 각각 살고 있는 겁니다. OSI 7계층은 택배 포장 단계와 비슷해서, 물건에 상자를 씌우고(헤더 추가) 또 큰 상자에 넣는 식으로 계층마다 정보를 덧붙여 보냅니다.
포트의 정의와 범위
포트는 네트워크에서 특정 프로세스를 식별하기 위한 논리적 주소입니다. IP 주소가 호스트를 식별한다면, 포트는 호스트 내의 특정 프로세스를 식별합니다. 범위는 0 ~ 65535이며 세 구간으로 나뉩니다 — 0~1023: 잘 알려진 포트(Well-known, HTTP 80 · HTTPS 443 · FTP 21 · SSH 22 · Telnet 23), 1024~49151: 등록된 포트, 49152~65535: 동적/사설 포트.
실습에서 5000번을 쓰는 이유
0~1023은 시스템이 예약해 두어 관리자 권한이 필요하고 이미 쓰이고 있을 가능성이 큽니다. 그래서 학습용 서버는 보통 1024보다 큰 번호(5000, 8080 등)를 씁니다. 이미 사용 중인 포트로 서버를 열면 BindException: Address already in use가 발생합니다.
OSI 7계층과 데이터 단위
통신 과정을 7단계로 나눈 표준 모델(Open System Interconnections)입니다. 데이터가 아래 계층으로 내려갈 때마다 헤더가 하나씩 붙으며 이름이 바뀝니다 — 상위에서 내려온 데이터에 헤더를 붙이면 세그먼트(Segment, 전송 계층) → 패킷(Packet, 네트워크 계층) → 프레임(Frame, 데이터링크 계층) → 비트(bit, 물리 계층)로 전송됩니다. 받는 쪽에서는 반대로 헤더를 하나씩 벗겨내며 올라갑니다.
번호
이름
예시
데이터 단위
7
응용(Application)
HTTP, FTP, DNS
데이터(메시지)
6
표현(Presentation)
인코딩(UTF-8), 암호화, 압축
5
세션(Session)
연결 시작·유지·종료 관리
4
전송(Transport)
TCP, UDP, 포트 번호
세그먼트(UDP는 데이터그램)
3
네트워크(Network)
IP, 라우터, ping
패킷
2
데이터링크(Data Link)
이더넷, MAC 주소, 스위치
프레임
1
물리(Physical)
케이블, 전기·광 신호
비트
자바 프로그래머가 다루는 층
자바의 소켓 프로그래밍은 주로 전송 계층(TCP/UDP) 위에서 이루어집니다. 우리가 new Socket("localhost", 5000)이라고 쓰면 그 아래 계층(IP 라우팅, 이더넷 프레임 등)은 OS와 하드웨어가 알아서 처리해 줍니다.
핵심 정리
IP = 호스트 식별, 포트 = 호스트 내 프로세스 식별.
포트 범위 0~65535 — 0~1023(잘 알려진 포트: HTTP 80, HTTPS 443, FTP 21), 1024~49151(등록), 49152~(동적).
학습용 서버는 1024 이상(5000·8080 등)을 쓰고, 중복되면 BindException이 난다.
OSI 계층을 내려갈수록 헤더가 붙으며 세그먼트 → 패킷 → 프레임 → 비트로 바뀐다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
IP 주소만으로는 부족해서 포트 번호가 필요한 이유는?
한 컴퓨터에서 여러 프로그램이 동시에 통신하기 때문입니다. IP는 "어느 컴퓨터"까지만 알려 주므로, 그 안의 어느 프로그램인지는 포트가 구분합니다. IP가 건물 주소라면 포트는 호수입니다.
OSI 7계층으로 나눠 생각하면 무엇이 좋아지나?
문제를 층으로 좁혀 진단할 수 있습니다. "핑은 되는데 접속이 안 된다"면 3계층은 정상이고 4~7계층 문제로 범위가 확 줄죠. 또 각 층이 독립적이라 한 층을 바꿔도 나머지에 영향이 없습니다.
💼 실무·코딩테스트에서는포트 번호는 실무에서 매일 만납니다 — 80(HTTP), 443(HTTPS), 3306(MySQL), 6379(Redis). 방화벽 설정과 포트 충돌이 흔한 이슈라, "이미 사용 중인 포트" 에러를 만나면 netstat로 확인하는 것이 기본 대응입니다.
TIP서버를 껐는데도 "Address already in use"가 계속 뜬다면, 이전 프로세스가 아직 종료되지 않았거나 TIME_WAIT 상태일 수 있습니다. 포트 번호를 바꾸거나 잠시 기다린 뒤 다시 실행해 보세요.
한 줄 요약자바는 java.net 패키지로 네트워크를 지원하며, TCP는 Socket(클라이언트)과 ServerSocket(서버), UDP는 DatagramSocket(송수신)과 DatagramPacket(데이터), IP 처리는 InetAddress가 담당한다.
쉽게 말하면Socket은 전화기, ServerSocket은 전화를 받는 교환대예요. 교환대는 accept() 상태로 전화가 올 때까지 가만히 기다리다가(블록), 전화가 오면 그 통화 전용 전화기(Socket)를 하나 만들어 줍니다. UDP의 DatagramPacket은 주소가 적힌 엽서라서, 연결 없이 그냥 우체통에 넣으면 됩니다.
Socket — 클라이언트 측
클라이언트가 서버에 연결할 때 사용합니다. TCP 연결을 지원해 신뢰성 있는 양방향 스트림 기반 통신을 제공하며, 연결을 통해 데이터를 읽고 쓸 수 있습니다. 주요 메서드 — Socket(String host, int port)(지정 호스트·포트에 연결), getInputStream()(읽기용 스트림), getOutputStream()(쓰기용 스트림), close()(소켓 닫고 리소스 해제).
ServerSocket — 서버 측
서버가 클라이언트의 연결 요청을 기다리고 수락할 때 사용합니다. 주요 메서드 — ServerSocket(int port)(지정 포트에서 서버 소켓 생성), accept()(클라이언트 연결을 기다리고 수락, 연결이 올 때까지 이 메서드는 블록됩니다), close(). accept()가 반환하는 것은 그 클라이언트와 통신할 Socket입니다.
InetAddress — IP 주소 처리
IP 주소를 표현하고 다루는 유틸리티를 제공합니다. DNS 조회(호스트 이름 ↔ IP 변환)도 담당합니다. 주요 메서드 — getByName(String host)(호스트 이름으로 IP 반환), getLocalHost()(로컬 호스트 IP), getHostAddress()(IP를 문자열로), getHostName()(호스트 이름).
DatagramSocket · DatagramPacket — UDP
DatagramSocket은 데이터를 전송·수신하는 소켓으로 비연결성 통신을 하며 신속하지만 전달·순서 보장이 없습니다. 주요 메서드 — DatagramSocket()(사용 가능한 포트에 바인드), DatagramSocket(int port)(지정 포트에 바인드), send(DatagramPacket), receive(DatagramPacket), close(). DatagramPacket은 전송·수신할 데이터 자체를 나타내며, 수신용은 DatagramPacket(byte[] buf, int length), 전송용은 여기에 주소와 포트를 추가한DatagramPacket(byte[] buf, int length, InetAddress address, int port)로 만듭니다. getData()·getLength()로 내용을 꺼냅니다.
URI · URL — 자원의 이름과 위치
URI(Uniform Resource Identifier)는 자원을 식별하는 문자열 전체를 뜻하고, URL(Uniform Resource Locator)은 그중 https://example.com:443/index.html처럼 어디에 있고 어떻게 접근하는지(프로토콜·호스트·포트·경로)까지 알려 주는 것입니다. 즉 URI ⊃ URL입니다. java.net에는 둘 다 클래스로 있습니다 — URI는 문자열을 구성 요소로 나누고 검사하며, URL은 getProtocol()·getHost()·getPort()·getPath()로 부분을 꺼내고 openStream()으로 그 자원을 읽을 수 있습니다. Java 20부터 new URL(...) 생성자가 deprecated되어, Java 21에서는 URI.create("https://…").toURL()로 만드는 것이 권장됩니다.
핵심 정리
TCP: Socket(클라이언트) + ServerSocket(서버, accept()는 연결이 올 때까지 블록).
소켓에서 getInputStream()·getOutputStream()을 얻어 IO 스트림처럼 다룬다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
InetAddress 클래스가 하는 일은?
도메인 이름과 IP 주소를 다루는 것입니다. InetAddress.getByName("google.com")이 DNS 조회를 거쳐 IP를 알아냅니다. 사람은 이름으로, 컴퓨터는 숫자로 다루므로 그 사이를 잇는 역할입니다.
URL과 URI의 차이를 간단히 말해보세요.
URI가 더 넓은 개념으로 "자원을 식별하는 문자열" 전체를 뜻하고, URL은 그중 위치까지 알려 주는 것입니다. 실무에서는 거의 구분 없이 쓰지만, URI ⊃ URL이라는 포함 관계는 알아 두면 좋습니다.
💼 실무·코딩테스트에서는요즘 실무에서는 저수준 Socket보다 HttpClient나 라이브러리를 씁니다. 다만 타임아웃·재시도·커넥션 풀을 이해하려면 그 아래에서 무슨 일이 일어나는지 알아야 하므로, 소켓 수준의 이해가 여전히 도움이 됩니다.
TIP소켓 통신은 결국 🛠️ IO 스트림과 같은 코드가 됩니다 — 다만 파일 대신 네트워크에 연결된 스트림일 뿐입니다. IO를 이해했다면 소켓의 절반은 이미 아는 셈입니다.
06
TCP/IP 소켓 프로그래밍 — 서버와 클라이언트
ServerSocketacceptPrintWriterBufferedReader
한 줄 요약서버는 ServerSocket으로 포트를 열고 accept()로 연결을 기다리다가 연결된 Socket의 입출력 스트림으로 통신하며, 클라이언트는 Socket으로 서버에 접속해 같은 방식으로 읽고 쓴다.
쉽게 말하면서버는 가게를 열고 손님을 기다리는 것(accept)이고 클라이언트는 주소를 알고 찾아가는 것(new Socket)이에요. 연결되면 둘 사이에 말하는 파이프(OutputStream)와 듣는 파이프(InputStream)가 생기고, 그다음부터는 파일 입출력과 똑같이 읽고 씁니다.
서버의 흐름
① new ServerSocket(5000)으로 5000번 포트를 엽니다. ② accept()를 호출하면 클라이언트가 연결할 때까지 멈춰서 기다립니다(블로킹). ③ 연결이 들어오면 그 클라이언트 전용 Socket이 반환됩니다. ④ 그 소켓의 getInputStream()/getOutputStream()으로 데이터를 주고받습니다. ⑤ while(true)로 다시 accept()로 돌아가 다음 손님을 받습니다.
스트림을 감싸는 이유
소켓이 주는 것은 byte 단위의 InputStream/OutputStream입니다. 텍스트를 한 줄씩 편하게 다루기 위해 읽는 쪽은 new BufferedReader(new InputStreamReader(input))로, 쓰는 쪽은 new PrintWriter(output, true)로 감쌉니다. PrintWriter의 두 번째 인자 true는 autoFlush로, println()할 때마다 자동으로 flush되어 상대에게 즉시 전달됩니다(이걸 빠뜨리면 메시지가 버퍼에 남아 "아무 응답이 없는" 것처럼 보입니다).
readLine()이 null을 반환하는 시점
while ((text = reader.readLine()) != null)은 상대가 연결을 끊을 때까지 계속 읽는 구조입니다. 연결이 끊기면 readLine()이 null을 반환해 반복문이 끝납니다. 이것이 소켓 통신의 표준적인 수신 루프 형태입니다.
왜 서버 코드에 스레드가 등장하는가
아래 서버 예제는 new ServerThread(socket).start()로 연결마다 스레드를 하나씩 만듭니다. 그렇게 하지 않으면 한 클라이언트와 통신하는 동안 다음 클라이언트를 받지 못하기 때문입니다 — 자세한 이유는 08. 멀티스레드 서버에서 다룹니다.
JAVATCPServer.java
import java.io.*;
import java.net.*;
public class TCPServer {
public static void main(String[] args) {
// try-with-resources: 블록이 끝나면 자동으로 close()된다
try (ServerSocket serverSocket = new ServerSocket(5000)) {
System.out.println("Server is listening on port 5000");
while (true) {
Socket socket = serverSocket.accept(); // 연결이 올 때까지 여기서 멈춘다
System.out.println("New client connected");
new ServerThread(socket).start(); // 클라이언트마다 스레드 하나씩
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
}
class ServerThread extends Thread {
private Socket socket;
public ServerThread(Socket socket) { this.socket = socket; }
public void run() {
try (InputStream input = socket.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(input));
OutputStream output = socket.getOutputStream();
PrintWriter writer = new PrintWriter(output, true)) { // true = autoFlush
String text;
while ((text = reader.readLine()) != null) { // 끊길 때까지 계속 읽는다
System.out.println("Received: " + text);
writer.println("Echo: " + text); // 받은 걸 그대로 되돌려 준다
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
}
JAVATCPClient.java
import java.io.*;
import java.net.*;
public class TCPClient {
public static void main(String[] args) {
String hostname = "localhost"; // 같은 PC에서 테스트할 때 쓰는 루프백 주소
int port = 5000;
try (Socket socket = new Socket(hostname, port)) { // 서버에 접속
OutputStream output = socket.getOutputStream();
PrintWriter writer = new PrintWriter(output, true);
// 키보드 입력을 읽기 위한 스트림 (소켓과는 별개)
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
// 서버 응답을 읽기 위한 스트림
BufferedReader responseReader =
new BufferedReader(new InputStreamReader(socket.getInputStream()));
String text;
while (true) {
System.out.print("Enter text: ");
text = reader.readLine(); // 키보드 입력
writer.println(text); // 서버로 전송
System.out.println(responseReader.readLine()); // 서버 응답 출력
}
} catch (UnknownHostException ex) {
System.out.println("Server not found: " + ex.getMessage());
} catch (IOException ex) {
System.out.println("I/O error: " + ex.getMessage());
}
}
}
핵심 정리
서버: new ServerSocket(port) → accept()(블로킹) → 반환된 Socket으로 통신.
클라이언트: new Socket(host, port)로 접속 후 같은 방식으로 스트림 사용.
PrintWriter(output, true)의 true는 autoFlush — 빠뜨리면 메시지가 전달되지 않는다.
readLine()이 null을 반환하면 상대가 연결을 끊은 것이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
accept()가 '블로킹된다'는 게 무슨 뜻이고 왜 그렇게 만들었나?
클라이언트가 접속할 때까지 그 줄에서 멈춰 기다린다는 뜻입니다. 계속 확인하며 CPU를 태우는 대신 대기 상태로 들어가 자원을 아끼는 방식이죠. 대신 기다리는 동안 다른 일을 못 한다는 것이 한계이고, 그래서 스레드가 필요해집니다.
서버와 클라이언트 코드에서 결정적으로 다른 부분은?
서버는 ServerSocket으로 포트를 열고 기다리고(accept), 클라이언트는 Socket으로 먼저 접속을 겁니다. 연결이 맺어진 뒤부터는 양쪽 다 똑같이 스트림으로 읽고 씁니다 — 대칭이 되는 것이죠.
💼 실무·코딩테스트에서는소켓 프로그래밍은 웹 서버가 어떻게 동작하는지 이해하는 바닥입니다. 스프링으로 API를 만들 때도 그 아래에는 톰캣이 소켓을 열고 accept하는 이 구조가 그대로 있습니다. 블로킹 IO의 한계를 알면 나중에 비동기·논블로킹(Netty, WebFlux)을 배울 때 왜 필요한지 바로 이해됩니다.
TIP테스트할 때는 반드시 서버를 먼저 실행하고 클라이언트를 나중에 실행하세요. 순서가 바뀌면 ConnectException: Connection refused가 납니다. 이클립스/VS Code에서는 실행 창을 두 개 띄워 각각 실행하면 됩니다.
07
UDP 소켓 프로그래밍
DatagramSocketDatagramPacket비연결buffer
한 줄 요약UDP는 연결 과정 없이 DatagramSocket으로 DatagramPacket을 send/receive하며, 데이터를 byte 배열 버퍼에 담아 주소와 포트를 패킷에 직접 실어 보낸다.
쉽게 말하면TCP가 전화라면 UDP는 엽서예요. 전화는 연결이 되어야 말할 수 있지만, 엽서는 주소만 적어서 우체통에 넣으면 끝입니다. 받는 사람이 이사를 갔든 엽서가 분실되든 알 수 없지만, 대신 준비 과정이 없어 훨씬 간단하고 빠릅니다.
TCP와 코드가 다른 점
① accept()나 connect가 없습니다 — 연결 과정 자체가 없기 때문입니다. ② 스트림이 아니라 패킷을 다룹니다 — getInputStream() 대신 byte[] buffer를 직접 준비합니다. ③ 주소와 포트를 패킷에 직접 넣습니다 — TCP는 연결 시점에 상대가 정해지지만 UDP는 보낼 때마다 목적지를 지정합니다.
서버의 흐름
new DatagramSocket(5000)으로 포트를 열고, 받을 데이터를 담을 byte 배열 버퍼와 그 버퍼를 감싼 DatagramPacket을 만듭니다. socket.receive(packet)은 데이터가 올 때까지 블록되고, 도착하면 packet.getData()와 getLength()로 내용을 꺼냅니다. 응답을 보내려면 packet.getAddress()·getPort()로 보낸 사람의 정보를 알아내 새 패킷을 만들어 send()합니다.
클라이언트의 흐름
new DatagramSocket()(포트를 지정하지 않으면 사용 가능한 포트에 자동 바인드)으로 소켓을 만들고, InetAddress.getByName(hostname)으로 목적지 주소를 얻습니다. 보낼 문자열을 getBytes()로 byte 배열로 바꿔 주소와 포트를 포함한 패킷을 만들어 send()하고, 응답은 receive()로 받습니다.
버퍼 크기에 주의
byte[] buffer = new byte[512]처럼 버퍼 크기를 정하는데, 이 크기를 넘는 데이터는 잘립니다. 그리고 받은 데이터를 문자열로 만들 때는 반드시 new String(packet.getData(), 0, packet.getLength())처럼 실제 받은 길이만큼만 잘라야 합니다 — 그냥 new String(buffer)로 하면 뒤에 이전 데이터나 빈 공간이 섞입니다.
되돌려 보낼 때도 getLength()
수업 원본 서버는 응답 패킷을 new DatagramPacket(buffer, buffer.length, address, port)로 만들어 받은 글자 수와 상관없이 512바이트 전체를 돌려보냅니다. 원본 클라이언트는 받기 전용 버퍼로 받은 뒤 getLength()(=512)만큼 문자열로 만들기 때문에, 짧은 메시지 뒤에 빈 바이트·이전 찌꺼기가 같이 찍힙니다. 아래 코드는 응답 길이를 packet.getLength()로 고친 버전이고, 클라이언트도 보내기·받기 버퍼를 나눴습니다(보낸 배열을 받기에 재사용하면 받을 수 있는 크기가 보낸 길이로 줄어, 우연히 맞아 보일 뿐입니다). 원본 코드와 수정 과정은 19일차에 있습니다.
JAVAUDPServer.java
import java.io.*;
import java.net.*;
public class UDPServer {
public static void main(String[] args) {
try (DatagramSocket socket = new DatagramSocket(5000)) {
byte[] buffer = new byte[512]; // 받을 데이터를 담을 버퍼
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
System.out.println("Server is listening on port 5000");
while (true) {
socket.receive(packet); // 데이터가 올 때까지 블록
// 실제 받은 길이만큼만 문자열로 변환해야 한다
String received = new String(packet.getData(), 0, packet.getLength());
System.out.println("Received: " + received);
// 보낸 사람의 주소·포트를 패킷에서 꺼내 그대로 되돌려 보낸다
InetAddress address = packet.getAddress();
int port = packet.getPort();
// 길이는 buffer.length(항상 512)가 아니라 실제 받은 길이 getLength()
DatagramPacket reply =
new DatagramPacket(buffer, packet.getLength(), address, port);
socket.send(reply);
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
}
JAVAUDPClient.java
import java.io.*;
import java.net.*;
public class UDPClient {
public static void main(String[] args) {
String hostname = "localhost";
int port = 5000;
try (DatagramSocket socket = new DatagramSocket()) { // 포트 자동 할당
InetAddress address = InetAddress.getByName(hostname);
byte[] receiveBuffer = new byte[512]; // 받기 전용 버퍼 (보내기와 분리)
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String text;
while (true) {
System.out.print("Enter text: ");
text = reader.readLine();
byte[] sendBuffer = text.getBytes(); // 문자열 → byte 배열
// 전송용 패킷: 데이터 + 길이 + 목적지 주소 + 포트
DatagramPacket packet =
new DatagramPacket(sendBuffer, sendBuffer.length, address, port);
socket.send(packet);
// 수신용 패킷: 데이터 + 길이만 (주소 불필요)
packet = new DatagramPacket(receiveBuffer, receiveBuffer.length);
socket.receive(packet);
String received = new String(packet.getData(), 0, packet.getLength());
System.out.println("Received: " + received);
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
}
핵심 정리
UDP는 연결 과정이 없어 accept·connect가 없고 send/receive만 있다.
전송용 패킷은 데이터+길이+주소+포트, 수신용 패킷은 데이터+길이만 있으면 된다.
receive()는 데이터가 도착할 때까지 블록된다.
받은 데이터는 new String(getData(), 0, getLength())로 실제 길이만큼만 변환한다.
받은 것을 되돌려 보낼 때도 길이는 buffer.length가 아니라 packet.getLength()를 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
UDP에는 왜 accept()가 없나?
연결이라는 개념 자체가 없기 때문입니다. TCP처럼 통로를 만들지 않고 주소를 붙여 그냥 던지므로, 받아들일 연결도 없죠. 그래서 DatagramSocket 하나로 보내기와 받기를 모두 합니다.
DatagramPacket을 만들 때 보내기용과 받기용이 다른 이유는?
받을 때는 목적지가 필요 없기 때문입니다. 받기용은 빈 버퍼와 크기만 있으면 되고, 보내기용은 데이터 + 목적지 주소와 포트가 있어야 합니다. 방향에 따라 필요한 정보가 다른 것입니다.
💼 실무·코딩테스트에서는UDP는 실시간성이 정확성보다 중요한 곳에 쓰입니다 — 게임, 화상통화, DNS 조회. 최근에는 HTTP/3가 UDP 기반(QUIC)으로 바뀌었다는 점도 알아 두면 좋습니다. "신뢰성은 애플리케이션에서 직접 구현"하는 방향으로 바뀐 것이죠.
TIPUDP는 순서와 도착을 보장하지 않으므로, 순서가 중요한 데이터를 보내려면 애플리케이션에서 직접 번호를 붙이고 재전송 로직을 만들어야 합니다. 그럴 바에는 TCP를 쓰는 게 나은 경우가 대부분입니다.
08
멀티스레드 서버와 채팅 프로그램
멀티스레드accept 루프작업 스레드채팅
한 줄 요약메인 스레드는 accept()로 연결만 받고 각 클라이언트의 요청 처리는 별도의 작업 스레드에 위임하면, 여러 클라이언트를 동시에 처리하는 서버를 만들 수 있다.
쉽게 말하면단일 스레드 서버는 직원 한 명인 식당이에요. 첫 손님 응대가 끝날 때까지 두 번째 손님은 문밖에서 기다립니다. 멀티스레드 서버는 손님이 올 때마다 담당 직원을 한 명씩 붙여 주는 것이라, 여러 손님이 동시에 식사할 수 있습니다.
왜 멀티스레드가 필요한가
서버가 한 클라이언트와 readLine()으로 통신하는 동안에는 accept()로 돌아갈 수 없습니다. 그러면 두 번째 클라이언트는 첫 번째가 접속을 끊을 때까지 기다려야 합니다. 각 클라이언트의 요청을 별도 스레드에서 처리하면 응답 속도가 빨라지고 동시에 여러 클라이언트를 받을 수 있습니다.
서버의 2층 구조
메인 서버 스레드는 클라이언트의 연결 요청을 수신하고, 각 요청을 처리할 새 스레드를 생성해 작업을 위임하는 일만 합니다. 작업 스레드는 개별 클라이언트의 요청을 처리하고 응답을 만들어 전송합니다. 이 분리 덕분에 메인 스레드는 항상 accept()로 되돌아가 대기할 수 있습니다.
멀티스레드 서버의 이점
성능 향상(여러 요청을 병렬 처리) · 응답 시간 감소(대기 시간 축소) · 확장성(단일 스레드 서버보다 훨씬 많은 클라이언트를 동시에 처리 — 단, 접속마다 스레드를 새로 만드는 방식은 수천 명 규모에서 한계가 있어 실무에서는 스레드 풀을 씁니다. 아래 스스로 확인·TIP 참고) · 실시간 응답(온라인 게임·실시간 스트리밍처럼 즉각적인 응답이 필요한 애플리케이션에 필수).
채팅 프로그램으로 확장하기
에코 서버(받은 걸 그대로 돌려줌)를 채팅 서버로 바꾸려면 접속한 모든 클라이언트의 출력 스트림을 컬렉션에 보관했다가, 한 명이 메시지를 보내면 전체에게 다시 뿌리면(broadcast) 됩니다. 여기서 broadcast는 "접속자 모두에게 하나씩 보낸다"는 프로그램 안의 이름일 뿐이고, 실제로는 각 클라이언트와의 TCP 연결로 따로따로 보냅니다 — net-02의 네트워크 브로드캐스트(브로드캐스트 주소로 한 번에 전송)와는 다른 뜻입니다. 이때 여러 스레드가 같은 목록을 동시에 건드리므로 동기화가 필요하고, 목록 자료구조로는 Collections.synchronizedList()나 CopyOnWriteArrayList를 씁니다(닉네임 → 출력 스트림처럼 키로 관리하려면 Map인 ConcurrentHashMap) — 교안의 마지막 실습(채팅 프로그램 구현)이 정확히 이 구조입니다.
JAVAChatServer.java (에코 서버 → 채팅 서버 확장)
import java.io.*;
import java.net.*;
import java.util.*;
public class ChatServer {
// 접속한 모든 클라이언트의 출력 스트림 — 여러 스레드가 공유하므로 동기화 필요
static List<PrintWriter> clients =
Collections.synchronizedList(new ArrayList<>());
public static void main(String[] args) {
try (ServerSocket serverSocket = new ServerSocket(5000)) {
System.out.println("채팅 서버 시작 (포트 5000)");
while (true) {
Socket socket = serverSocket.accept(); // 메인 스레드는 연결만 받는다
new ClientHandler(socket).start(); // 처리는 작업 스레드에 위임
}
} catch (IOException e) {
e.printStackTrace();
}
}
// 접속한 전체에게 메시지를 뿌린다
static void broadcast(String msg) {
synchronized (clients) { // 순회 중 목록이 바뀌지 않도록 잠근다
for (PrintWriter w : clients) w.println(msg);
}
}
}
class ClientHandler extends Thread {
private Socket socket;
public ClientHandler(Socket socket) { this.socket = socket; }
public void run() {
PrintWriter writer = null;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream()))) {
writer = new PrintWriter(socket.getOutputStream(), true);
ChatServer.clients.add(writer); // 목록에 등록
ChatServer.broadcast("[알림] 새 사용자 입장 (현재 "
+ ChatServer.clients.size() + "명)");
String text;
while ((text = reader.readLine()) != null) { // 끊길 때까지 수신
ChatServer.broadcast("> " + text); // 받은 메시지를 전체에게
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (writer != null) ChatServer.clients.remove(writer); // 나가면 목록에서 제거
ChatServer.broadcast("[알림] 사용자 퇴장");
try { socket.close(); } catch (IOException e) { }
}
}
}
핵심 정리
메인 스레드는 accept()만 담당하고, 클라이언트 처리는 작업 스레드에 위임한다.
이 구조 덕분에 여러 클라이언트를 동시에 처리할 수 있다(성능·응답시간·확장성).
채팅 서버는 접속자들의 출력 스트림을 목록에 보관했다가 전체에 broadcast한다.
여러 스레드가 공유하는 목록은 Collections.synchronizedList + synchronized 순회로 보호한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
접속마다 스레드를 만드는 방식의 한계는 무엇인가?
동시 접속이 수천이 되면 스레드도 수천 개가 되어 메모리와 컨텍스트 스위칭 비용이 폭발합니다. 스레드 하나에 스택만 1MB 정도 잡히니까요. 그래서 실무에서는 스레드 풀로 개수를 제한하거나 비동기 논블로킹 방식으로 갑니다.
채팅 서버에서 접속자 목록을 synchronizedList로 감싼 이유는?
여러 스레드가 동시에 목록을 고치기 때문입니다. 한 스레드가 순회하며 방송하는 동안 다른 스레드가 목록에 추가·삭제하면ConcurrentModificationException이 나거나 데이터가 깨집니다. 동기화로 한 번에 하나만 손대게 막는 것입니다.
💼 실무·코딩테스트에서는이 카드가 자바 과정의 종합판입니다 — 소켓 + 스레드 + 컬렉션 + 예외 처리가 전부 들어가죠. 실무에서 실시간 기능(채팅, 알림, 주식 시세)을 만들면 요즘은 WebSocket을 쓰지만, 동시 접속을 어떻게 감당하는가라는 문제의 본질은 똑같습니다.
TIP실무에서는 접속마다 스레드를 새로 만드는 대신 스레드 풀(ExecutorService)을 씁니다. 접속자가 수천 명이면 스레드 생성 비용과 메모리가 감당이 안 되기 때문입니다 — Executors.newFixedThreadPool(10)처럼 미리 만들어 둔 스레드를 재사용합니다.
🧪 5. 자바 실습과제 12문제
「자바 실습문제」 과제 12문항의 접근 방법과 풀이 단계, 핵심 코드를 정리했습니다. 앞쪽(1~6번)은 반복문과 % 연산만으로 풀 수 있는 수학 문제, 7번(개미수열)은 문자열 처리, 뒤쪽(8~12번)은 배열·클래스·객체지향 설계가 필요한 문제입니다. 먼저 직접 풀어 보고 막힐 때 펼쳐 보세요.
01
약수 구하기
반복문나머지 연산기초
한 줄 요약1부터 그 수까지 반복하며 나머지가 0이 되는 수를 출력하면 약수를 모두 구할 수 있다.
쉽게 말하면약수 구하기는 사탕 12개를 봉지에 똑같이 나눠 담을 수 있는 개수를 찾는 것과 같아요. 1개씩·2개씩·3개씩·4개씩·6개씩·12개씩은 딱 떨어지지만 5개씩은 남습니다. "딱 떨어진다"를 코드로 옮기면 12 % i == 0입니다.
문제 정의
약수란 어떤 수를 나누어 떨어지게 하는 수입니다. 12를 나누어 떨어지게 하는 수는 1, 2, 3, 4, 6, 12이므로 12의 약수는 이 여섯 개입니다.
핵심 도구는 나머지 연산자 %
%는 나눗셈의 나머지를 구하는 연산자입니다. 12 % 5는 2, 12 % 4는 0입니다. 즉 나머지가 0이면 나누어 떨어진다는 뜻이므로, 이 한 줄의 조건이 이 문제와 이후 여러 문제(최대공약수·완전수·친화수)의 공통 뼈대가 됩니다.
메서드로 만들어 두면 재사용된다
약수를 구하는 로직은 4번 친화수·5번 완전수에서 계속 필요합니다. 처음부터 sumOfProperDivisors(int n)(자기 자신을 뺀 약수의 합)처럼 메서드로 분리해 두면 뒤 문제를 훨씬 빠르게 풀 수 있습니다.
풀이 단계
대상 숫자 num을 정한다.
for (int i = 1; i <= num; i++)로 1부터 num까지 반복한다.
if (num % i == 0)이면 i는 약수 → 출력하거나 배열/리스트에 담는다.
JAVADivisor.java
public class Divisor {
// 약수를 화면에 출력한다
public static void printDivisors(int num) {
System.out.print(num + "의 약수: ");
for (int i = 1; i <= num; i++) {
if (num % i == 0) { // 나머지가 0 = 나누어 떨어진다
System.out.print(i + " ");
}
}
System.out.println();
}
// 자기 자신을 제외한 약수(진약수)의 합 — 4번·5번 문제에서 재사용한다
public static int sumOfProperDivisors(int num) {
int sum = 0;
for (int i = 1; i < num; i++) { // i < num : 자기 자신은 제외
if (num % i == 0) sum += i;
}
return sum;
}
public static void main(String[] args) {
printDivisors(12); // 1 2 3 4 6 12
System.out.println(sumOfProperDivisors(220)); // 284
}
}
핵심 정리
"나누어 떨어진다" = num % i == 0.
약수는 1부터 자기 자신까지 반복하며 찾는다.
진약수(자기 자신 제외)는 i < num까지만 반복한다.
약수 합 구하기를 메서드로 분리하면 4·5번 문제에서 그대로 재사용된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
약수를 구할 때 n의 절반까지만 확인해도 되는 이유는?
n의 절반을 넘는 수로는 나누어떨어질 수 없기 때문입니다(n 자신 제외). 더 줄이면 √n까지만 확인하고 짝을 이루는 약수를 함께 구하는 방법도 있습니다 — 12의 약수를 구할 때 2를 찾으면 6도 자동으로 알게 되죠.
√n까지만 도는 최적화가 왜 정당한가?
약수는 항상 쌍으로 존재하고 그 쌍의 한쪽은 반드시 √n 이하이기 때문입니다. a × b = n에서 둘 다 √n보다 크면 곱이 n을 넘어 버리니까요. 그래서 √n까지만 봐도 모든 쌍을 빠짐없이 찾습니다.
💼 실무·코딩테스트에서는약수·소수 판별은 코딩테스트 기초 중의 기초이고, √n 최적화는 시간 초과를 피하는 첫 무기입니다. n이 100만을 넘어가면 이 최적화 없이는 통과가 안 됩니다. 소수를 많이 구해야 하면 에라토스테네스의 체로 넘어갑니다.
TIP성능을 생각한다면 i * i <= num까지만 돌면서 i와 num/i를 짝으로 담는 방법도 있습니다(약수는 항상 짝을 이루므로). 이때 i == num / i인 경우(예: 36에서 6)는 짝이 자기 자신이므로 한 번만 담아야 합니다. 다만 학습 단계에서는 먼저 단순하게 풀고 나중에 개선하는 순서가 좋습니다.
02
최대공약수(GCD) 구하기
GCD유클리드while
한 줄 요약두 수가 같아질 때까지 큰 수에서 작은 수를 계속 빼면 그 값이 최대공약수이며, 뺄셈 대신 나머지 연산을 쓰면 훨씬 빠른 유클리드 호제법이 된다.
쉽게 말하면가로 10cm, 세로 20cm 종이를 정사각형으로 남김없이 자르는 최대 크기를 찾는 문제예요. 큰 쪽에서 작은 쪽만큼 계속 잘라내다 보면 결국 두 변이 같아지는데, 그 길이가 최대공약수(10cm)입니다.
문제 정의
최대공약수는 두 수의 약수 중 공통된 약수 가운데 가장 큰 수입니다. 10의 약수는 1, 2, 5, 10이고 20의 약수는 1, 2, 4, 5, 10, 20이므로 공통 약수 중 가장 큰 10이 최대공약수입니다.
방법 1: 소인수분해
두 수를 공통으로 나눌 수 있는 소수로 계속 나눠서, 나눈 소수들을 모두 곱하면 최대공약수가 됩니다. 10과 20은 2로 나누고(5, 10) 다시 5로 나누면(1, 2) 더 나눌 수 없으므로 2 × 5 = 10입니다. 손으로 풀 때 쓰는 방식이지만 코드로 옮기기는 번거롭습니다.
방법 2: 뺄셈 반복 (과제에서 제시한 방법)
두 수가 같아질 때까지 큰 수에서 작은 수를 뺍니다. (10, 20) → (10, 10) → 같아졌으므로 10이 최대공약수입니다. while문 하나로 구현되어 코드가 매우 단순합니다.
방법 3: 유클리드 호제법 (개선판)
뺄셈을 반복하는 대신 나머지 연산을 쓰면 훨씬 빠릅니다. gcd(a, b) = gcd(b, a % b)이고 b가 0이 되면 a가 답입니다. (10, 20) → (20, 10) → (10, 0) → 10. 뺄셈 방식으로 1,000,000과 1을 계산하면 100만 번 반복하지만 호제법은 두 번이면 끝납니다.
풀이 단계 (뺄셈 방식)
두 수 a, b를 준비한다.
while (a != b) 반복 — 두 수가 같아지면 종료.
반복 안에서 큰 쪽에서 작은 쪽을 뺀다(if (a > b) a -= b; else b -= a;).
반복이 끝나면 a(= b)가 최대공약수다.
JAVAGcd.java
public class Gcd {
// 방법 2: 두 수가 같아질 때까지 큰 수에서 작은 수를 뺀다 (과제 제시 방법)
public static int gcdBySub(int a, int b) {
while (a != b) {
if (a > b) a -= b;
else b -= a;
}
return a; // 같아진 값이 최대공약수
}
// 방법 3: 유클리드 호제법 — 나머지가 0이 될 때까지 반복 (훨씬 빠르다)
public static int gcd(int a, int b) {
while (b != 0) {
int temp = a % b;
a = b;
b = temp;
}
return a;
}
// 재귀로 쓰면 한 줄 (같은 알고리즘)
public static int gcdRecursive(int a, int b) {
return (b == 0) ? a : gcdRecursive(b, a % b);
}
public static void main(String[] args) {
System.out.println(gcdBySub(10, 20)); // 10
System.out.println(gcd(10, 20)); // 10
System.out.println(gcdRecursive(48, 18)); // 6
}
}
핵심 정리
최대공약수 = 두 수의 공통 약수 중 가장 큰 수.
뺄셈 방식: 두 수가 같아질 때까지 큰 수에서 작은 수를 뺀다.
유클리드 호제법: gcd(a,b) = gcd(b, a%b), b가 0이면 a가 답.
같은 알고리즘을 while(반복)과 재귀 두 방식으로 쓸 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
유클리드 호제법이 왜 성립하는지 직관적으로 설명해보세요.
두 수의 공약수는 그 차(또는 나머지)의 약수이기도 하기 때문입니다. gcd(a, b) = gcd(b, a % b)로 수를 계속 줄여 나가도 최대공약수는 그대로죠. 나머지가 0이 되는 순간의 값이 답입니다.
반복문 방식과 재귀 방식 중 무엇이 나은가?
둘 다 정당하고 성능도 비슷합니다. 재귀가 gcd(b, a%b)라는 정의를 그대로 옮겨 읽기 좋고, 반복문은 스택을 안 쓰니 깊이 걱정이 없습니다. 유클리드 호제법은 깊이가 로그 수준이라 재귀도 안전합니다.
💼 실무·코딩테스트에서는GCD·LCM은 분수 계산, 주기 문제, 최적화 문제에 계속 등장합니다. lcm(a,b) = a * b / gcd(a,b) 공식도 함께 외워 두면 좋은데, 큰 수에서는 a / gcd * b 순서로 계산해야 오버플로를 피할 수 있습니다.
TIP뺄셈 방식은 두 수 중 하나가 0이면 무한 루프에 빠집니다. 입력 검증(0 이하면 예외 처리)을 넣어 두면 좋고, 이런 상황이 사용자 정의 예외를 연습하기에 딱 좋은 지점입니다.
03
최소공배수(LCM) 구하기
LCMGCD 재사용공식
한 줄 요약두 수를 곱한 뒤 최대공약수로 나누면 최소공배수가 되므로, 2번 문제에서 만든 gcd 메서드를 그대로 재사용하면 한 줄로 끝난다.
쉽게 말하면2일마다 오는 버스와 4일마다 오는 버스가 다시 같은 날 만나는 첫 날을 찾는 문제예요. 2의 배수(2,4,6,8…)와 4의 배수(4,8,12…) 중 처음 겹치는 4가 답입니다.
문제 정의
최소공배수는 두 수의 공통 배수 중 가장 작은 수입니다. 2의 배수는 2, 4, 6, 8…이고 4의 배수는 4, 8, 12…이므로 공배수는 4, 8, 12…이고 그중 가장 작은 4가 최소공배수입니다.
공식: 두 수의 곱 ÷ 최대공약수
LCM(a, b) = a × b ÷ GCD(a, b)입니다. 2와 4라면 2×4 = 8을 최대공약수 2로 나눠 4가 됩니다. 즉 이 문제의 핵심은 최대공약수를 구하는 것이고, 2번 문제를 메서드로 만들어 뒀다면 그대로 가져다 쓰면 됩니다.
오버플로 주의
a * b를 먼저 계산하면 두 수가 클 때 int 범위를 넘어 음수가 나오는 오버플로가 발생할 수 있습니다(05. 진법과 음수 표현). a / gcd * b 순서로 나눗셈을 먼저 하면 이 위험을 줄일 수 있습니다.
풀이 단계
2번 문제의 gcd(a, b) 메서드를 준비한다.
lcm = a / gcd(a, b) * b로 계산한다(나눗셈을 먼저 해 오버플로 방지).
결과를 출력한다.
JAVALcm.java
public class Lcm {
public static int gcd(int a, int b) { // 2번 문제에서 만든 것을 재사용
while (b != 0) { int t = a % b; a = b; b = t; }
return a;
}
public static int lcm(int a, int b) {
return a / gcd(a, b) * b; // 나눗셈을 먼저 해서 오버플로 방지
}
// 참고: 공식을 모른다면 배수를 하나씩 늘려가며 찾을 수도 있다
public static int lcmByLoop(int a, int b) {
int big = Math.max(a, b);
int n = big;
while (n % a != 0 || n % b != 0) { // 둘 다 나누어 떨어질 때까지
n += big;
}
return n;
}
public static void main(String[] args) {
System.out.println(lcm(2, 4)); // 4
System.out.println(lcm(10, 20)); // 20
System.out.println(lcmByLoop(6, 8)); // 24
}
}
핵심 정리
최소공배수 = 두 수의 곱 ÷ 최대공약수.
2번 문제의 gcd 메서드를 재사용하면 코드가 한 줄로 끝난다.
a * b / gcd 대신 a / gcd * b로 써서 오버플로를 피한다.
공식을 모르면 큰 수의 배수를 늘려가며 둘 다 나눠떨어지는 첫 수를 찾아도 된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
LCM을 GCD로 구하는 공식이 성립하는 이유는?
두 수를 소인수로 분해해 보면 GCD는 공통 인수의 곱, LCM은 모든 인수를 최대 지수로 취한 곱입니다. 그래서 GCD × LCM = a × b가 성립하고, 여기서 LCM = a × b / GCD가 나옵니다.
a * b가 오버플로할 수 있을 때 어떻게 계산해야 하나?
나누기를 먼저 합니다 — a / gcd * b. a는 gcd로 반드시 나누어떨어지므로 정확도 손실 없이 값을 줄인 뒤 곱할 수 있습니다. 순서만 바꿔도 오버플로를 크게 줄일 수 있습니다.
💼 실무·코딩테스트에서는계산 순서를 바꿔 오버플로를 피하는 이 기법은 코딩테스트에서 두루 쓰입니다. 조합 계산(nCr)이나 확률 문제에서도 곱하기 전에 나눌 수 있으면 나누는 것이 정석입니다.
TIP1·2·3번 문제가 연속으로 나온 이유는 "메서드로 나누면 다음 문제가 쉬워진다"는 것을 체험시키기 위해서입니다. 문제를 풀 때마다 "이 로직을 재사용할 수 있게 잘라 둘까?"를 생각해 보세요.
04
친화수 구하기
진약수220과 284메서드 재사용
한 줄 요약a의 진약수 합이 b이고 b의 진약수 합이 다시 a이면 두 수는 친화수이므로, 진약수 합을 구하는 메서드 하나를 두 번 호출해 판별한다.
쉽게 말하면친화수는 서로를 가리키는 두 사람이에요. A에게 "네 진약수를 다 더하면?"이라고 물으면 B라고 답하고, B에게 물으면 A라고 답합니다. 220과 284가 그런 사이입니다.
문제 정의
두 수 각각의 진약수(자기 자신을 제외한 약수)의 합이 서로의 값이 되는 관계를 친화수라고 합니다. 220의 약수는 1, 2, 4, 5, 10, 11, 20, 22, 44, 55, 110, 220으로 자기 자신을 포함한 합은 504이고, 자기 자신을 제외하면 284가 됩니다. 반대로 284의 진약수(1, 2, 4, 71, 142) 합은 220입니다. 그래서 220과 284는 친화수입니다.
핵심은 "자기 자신 제외"
이 문제에서 가장 많이 하는 실수가 자기 자신을 포함해서 더하는 것입니다. 반복문 조건을 i <= num이 아니라 i < num으로 써야 진약수만 더해집니다.
판별 방법
220의 진약수 합을 구해 284가 나오면, 그 284의 진약수 합을 다시 구해 원래 수(220)로 돌아오는지 확인합니다. 돌아오면 친화수입니다. 이때 a != b 조건을 함께 확인해야 완전수가 친화수로 잘못 판정되는 것을 막을 수 있습니다(6의 진약수 합은 6이라 자기 자신으로 돌아오기 때문).
풀이 단계
진약수 합을 구하는 메서드 sumOfProperDivisors(n)을 만든다(1번 문제에서 이미 만들었다면 재사용).
int b = sumOfProperDivisors(a);로 a의 짝을 구한다.
sumOfProperDivisors(b) == a이고 a != b이면 친화수다.
범위를 훑으며 찾을 때는 중복 출력을 막기 위해 a < b일 때만 출력한다.
JAVAAmicableNumber.java
public class AmicableNumber {
// 자기 자신을 제외한 약수(진약수)의 합
public static int sumOfProperDivisors(int num) {
int sum = 0;
for (int i = 1; i < num; i++) { // i < num 이 핵심! (자기 자신 제외)
if (num % i == 0) sum += i;
}
return sum;
}
// 두 수가 친화수인지 판별
public static boolean isAmicable(int a) {
int b = sumOfProperDivisors(a); // a의 진약수 합
if (a == b) return false; // 완전수는 친화수가 아니다
return sumOfProperDivisors(b) == a; // 다시 a로 돌아오면 친화수
}
public static void main(String[] args) {
System.out.println(sumOfProperDivisors(220)); // 284
System.out.println(sumOfProperDivisors(284)); // 220
System.out.println(isAmicable(220)); // true
// 1 ~ 1300 범위에서 친화수 쌍 찾기
for (int a = 2; a <= 1300; a++) {
int b = sumOfProperDivisors(a);
if (a < b && sumOfProperDivisors(b) == a) { // a < b : 중복 출력 방지
System.out.println(a + " 와 " + b + " 는 친화수입니다.");
}
}
// 220 와 284 / 1184 와 1210
}
}
핵심 정리
친화수 = 서로의 진약수 합이 상대방 값이 되는 두 수(220과 284).
진약수는 자기 자신을 제외하므로 반복 조건이 i < num이어야 한다.
a == b인 경우(완전수)를 제외해야 정확한 판별이 된다.
범위 탐색 시 a < b 조건으로 같은 쌍의 중복 출력을 막는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
친화수 판별에서 같은 계산을 두 번 하게 되는 지점은?
약수의 합을 구하는 부분입니다. a의 약수합을 구해 b를 얻고, 다시 b의 약수합을 구해 a와 비교하죠. 그래서 약수합 구하는 로직을 메서드로 분리하면 코드가 훨씬 깔끔해집니다.
자기 자신을 약수합에서 빼는 이유는?
친화수·완전수의 정의가 "자기 자신을 제외한 약수의 합"이기 때문입니다. 포함하면 모든 수가 자기 자신보다 커져 정의가 성립하지 않죠. 문제의 정의를 정확히 옮기는 것이 핵심입니다.
💼 실무·코딩테스트에서는친화수·완전수 같은 문제는 "반복되는 계산을 메서드로 뽑아내는" 연습으로서 가치가 큽니다. 실무에서도 같은 로직이 두 번 나오면 즉시 함수로 분리하는 것이 기본이고, 코딩테스트에서도 그래야 디버깅이 쉬워집니다.
TIP이 문제는 1번에서 만든 메서드를 그대로 쓰면 3줄로 끝납니다. 반대로 1번을 main에 몰아서 짰다면 여기서 처음부터 다시 써야 하죠 — 메서드 분리의 가치를 체감하기 좋은 문제입니다.
05
완전수 구하기
완전수6, 28진약수 합
한 줄 요약자기 자신을 제외한 약수들의 합이 자기 자신과 같으면 완전수이므로, 4번에서 만든 진약수 합 메서드에 == num 조건만 붙이면 된다.
쉽게 말하면완전수는 자기 부품들을 모두 모으면 정확히 자기 자신이 되는 수예요. 6의 진약수는 1, 2, 3이고 1+2+3 = 6이므로 6은 완전수입니다. 친화수가 "서로를 가리키는 두 사람"이라면 완전수는 "자기 자신을 가리키는 사람"입니다.
문제 정의
자기 자신을 제외한 약수들의 합이 자기 자신과 같은 수입니다. 6의 약수는 1, 2, 3, 6이고 자기 자신을 뺀 1+2+3이 6과 같으므로 완전수입니다. 그다음 완전수는 28(1+2+4+7+14), 그다음은 496, 8128입니다.
4번 문제와의 관계
구조가 친화수와 거의 같습니다. 친화수는 f(f(a)) == a && a != f(a), 완전수는 f(a) == a입니다. 즉 같은 메서드에 조건만 다르게 붙인 것이며, 이 관계를 이해하면 두 문제를 한 번에 정리할 수 있습니다.
과제가 요구하는 구조
과제 설명에 "자신을 제외한 약수의 합을 구하는 메서드를 작성하고 자신의 수와 같은지 비교한다"고 명시되어 있습니다. main에 몰아 쓰지 말고 메서드로 분리하는 것 자체가 채점 포인트입니다.
public class PerfectNumber {
// 자기 자신을 제외한 약수의 합 (4번 문제와 동일한 메서드)
public static int sumOfProperDivisors(int num) {
int sum = 0;
for (int i = 1; i < num; i++) {
if (num % i == 0) sum += i;
}
return sum;
}
// 완전수 판별: 진약수의 합이 자기 자신과 같은가?
public static boolean isPerfect(int num) {
return num > 1 && sumOfProperDivisors(num) == num;
}
public static void main(String[] args) {
System.out.println(isPerfect(6)); // true (1+2+3 = 6)
System.out.println(isPerfect(28)); // true (1+2+4+7+14 = 28)
System.out.println(isPerfect(12)); // false (1+2+3+4+6 = 16)
System.out.println("1 ~ 10000 사이의 완전수:");
for (int i = 2; i <= 10000; i++) {
if (isPerfect(i)) {
System.out.print(i + " "); // 6 28 496 8128
}
}
}
}
핵심 정리
완전수 = 진약수의 합이 자기 자신과 같은 수(6, 28, 496, 8128).
친화수와 같은 메서드를 쓰고 조건만 f(a) == a로 바꾼 것이다.
과제가 요구하는 것은 결과보다 메서드 분리 구조다.
1은 진약수가 없으므로 num > 1 조건으로 걸러 준다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
완전수와 친화수의 관계를 한 문장으로 말해보세요.
완전수는 친화수 식에서 a=b인 특수한 경우입니다. 친화수가 합(a)=b, 합(b)=a인데 여기서 a=b이면 합(a)=a가 되어 완전수 정의와 같아집니다. 다만 친화수는 서로 다른 두 수여야 하므로, 이 경우는 정의상 친화수에서 제외합니다(4번의 a != b 조건). 같은 로직으로 둘 다 풀린다는 점이 핵심입니다.
약수합 계산을 √n 최적화하면 무엇을 조심해야 하나?
완전제곱수에서 같은 약수를 두 번 더하는 것입니다. 36에서 6을 찾으면 짝도 6이라 한 번만 더해야 하죠. i * i == n인 경우를 따로 처리해야 합니다.
💼 실무·코딩테스트에서는수학 문제를 코드로 옮길 때 '정의를 그대로 옮기기'가 가장 안전한 출발입니다. 최적화는 그 뒤에 하는 것이고, 최적화할 때마다 경계 조건(완전제곱수 등)이 새로 생긴다는 걸 기억하면 실수가 줄어듭니다.
TIP범위를 10만 이상으로 늘리면 실행이 눈에 띄게 느려집니다 — 이중 반복이라 계산량이 급격히 늘기 때문입니다. 이럴 때 i * i <= num까지만 돌며 약수를 짝으로 더하는 방식으로 개선할 수 있습니다.
06
윤년 판별하기
윤년논리연산자400년 규칙
한 줄 요약4로 나누어 떨어지면서 100으로는 나누어 떨어지지 않거나, 400으로 나누어 떨어지면 윤년이다.
쉽게 말하면윤년 규칙은 3단계 체(sieve)예요. ① 4의 배수는 일단 통과 ② 그중 100의 배수는 탈락 ③ 그런데 400의 배수는 다시 부활. 그래서 1900년은 평년이고 2000년은 윤년입니다.
문제 정의
윤년은 2월 29일이 있어 1년이 366일이 되는 해입니다. 규칙은 — 4로 나누어 떨어지면 윤년이지만 100으로 나누어 떨어지면 평년이고, 그중에서도 400으로 나누어 떨어지면 다시 윤년입니다. 그래서 2000년은 윤년입니다.
조건식으로 옮기기
"4로 나누어 떨어지면서 100으로 나누어 떨어지지 않거나, 400으로 나누어 떨어지면 윤년"을 그대로 옮기면 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)입니다. 괄호가 중요합니다 — &&가 ||보다 우선순위가 높지만, 명시적으로 묶어야 읽는 사람이 헷갈리지 않습니다(07. 연산자).
왜 이런 복잡한 규칙인가
지구의 공전 주기가 정확히 365.25일이 아니라 약 365.2422일이기 때문입니다. 4년마다 하루를 더하면 조금 과하게 더해지므로 100년마다 한 번 빼고, 그것도 미세하게 어긋나 400년마다 다시 넣습니다.
확장 문제: 2000년~현재까지의 윤년 출력
판별 메서드를 만들어 두면 반복문으로 범위를 훑는 것은 간단합니다. 현재 연도는 java.time.LocalDate.now().getYear()나 Calendar로 얻을 수 있습니다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
윤년 조건 세 가지를 순서대로 말하고, 순서가 왜 중요한지 설명해보세요.
4로 나누어떨어지고, 100으로 나누어떨어지면 제외하되, 400으로 나누어떨어지면 다시 윤년입니다. 순서가 중요한 이유는 뒤 조건이 앞 조건의 예외이기 때문입니다 — 1900년은 4의 배수지만 100의 배수라 평년, 2000년은 400의 배수라 윤년입니다.
이 조건이 이렇게 복잡한 이유는?
지구의 공전 주기가 정확히 365.25일이 아니라 약 365.2422일이기 때문입니다. 4년마다 하루를 더하면 조금씩 초과하므로, 100년마다 한 번 빼고 400년마다 다시 더해 오차를 보정하는 것입니다.
💼 실무·코딩테스트에서는날짜 계산은 실무에서 직접 구현하면 거의 틀립니다 — 윤년, 시간대, 서머타임, 윤초까지 얽히니까요. 그래서 실무에서는 반드시 LocalDate 같은 표준 라이브러리를 씁니다. 다만 로직을 이해하고 라이브러리를 쓰는 것과 모르고 쓰는 것은 다릅니다.
TIP윤년 문제는 테스트 케이스를 잘 고르는 연습이기도 합니다. 2024(일반 윤년)·2023(평년)·1900(100의 배수 함정)·2000(400의 배수 함정) 네 가지만 통과하면 로직이 맞습니다.
07
개미수열(Look-and-say) 구현하기
개미수열문자열 처리StringBuilder
한 줄 요약1에서 시작해 앞 항을 "같은 숫자가 몇 개 있는지" 읽은 결과가 다음 항이 되는 수열로, 앞 글자와 다음 글자를 비교하며 개수를 세는 반복문으로 구현한다.
쉽게 말하면개미수열은 앞 줄을 소리 내어 읽은 것이 다음 줄이에요. 1을 보고 "1이 1개"라고 읽으면 11, 11을 보고 "1이 2개"라고 읽으면 12, 12를 보고 "1이 1개, 2가 1개"라고 읽으면 1121이 되는 식입니다.
수열의 규칙
1 → 11 → 12 → 1121 → 122111 → … 순으로 진행됩니다. 각 단계는 앞 항을 왼쪽부터 읽으면서 같은 숫자가 연속으로 몇 번 나오는지를 세어 기록합니다. 이 과제는 "숫자 → 개수" 순서로, 표준 Look-and-say는 "개수 → 숫자" 순서로 적습니다(표준이면 1 → 11 → 21 → 1211 → 111221). 예를 들어 이 과제에서 1 2는 "1이 1개, 2가 1개"이므로 11 21 → 1121이 됩니다.
구현 아이디어
문자열을 toCharArray()로 배열로 바꾼 뒤 인덱스를 하나씩 이동하며 앞 글자와 현재 글자가 같은지 비교합니다. 같으면 카운트를 올리고, 다르면 그때까지의 "숫자 + 개수"를 결과에 붙이고 카운트를 1로 초기화합니다. 마지막 묶음은 반복문이 끝난 뒤에 따로 붙여 줘야 합니다 — 이 마지막 처리를 빠뜨리는 것이 가장 흔한 실수입니다.
StringBuilder를 쓰는 이유
결과 문자열을 반복해서 이어 붙이므로 String의 +=를 쓰면 매번 새 객체가 만들어져 느려집니다(15. StringBuilder). 수열이 길어질수록 차이가 커지므로 StringBuilder.append()를 씁니다.
배경
이 수열은 베르나르 베르베르의 소설 「개미」에 등장해 유명해졌지만, 그보다 앞서 클리포드 스톨의 「뻐꾸기 알」에도 나옵니다. 수학적으로는 Look-and-say sequence라고 부릅니다.
풀이 단계
시작 문자열 "1"을 준비한다.
원하는 항 수만큼 반복한다.
각 반복에서 문자 배열을 순회하며 앞 글자와 같으면 카운트 증가, 다르면 이전숫자 + 개수를 결과에 붙이고 카운트를 1로 리셋한다.
반복이 끝난 뒤 마지막 묶음을 반드시 붙인다.
만들어진 문자열이 다음 항이 된다.
JAVAAntSequence.java
public class AntSequence {
// 한 항을 읽어 다음 항을 만든다
public static String next(String s) {
StringBuilder sb = new StringBuilder();
char[] c = s.toCharArray();
char prev = c[0]; // 비교 기준이 되는 앞 글자
int count = 1; // 같은 숫자가 몇 번 연속되는지
for (int i = 1; i < c.length; i++) {
if (c[i] == prev) {
count++; // 같으면 개수만 늘린다
} else {
sb.append(prev).append(count); // 다르면 "숫자 + 개수"를 기록하고
prev = c[i]; // 기준을 바꾸고
count = 1; // 개수를 초기화
}
}
sb.append(prev).append(count); // ★ 마지막 묶음 처리 (빠뜨리기 쉬움)
return sb.toString();
}
public static void main(String[] args) {
String s = "1";
for (int i = 1; i <= 6; i++) {
System.out.println(i + "번째: " + s);
s = next(s);
}
// 1번째: 1
// 2번째: 11
// 3번째: 12
// 4번째: 1121
// 5번째: 122111
// 6번째: 112213
}
}
핵심 정리
개미수열 = 앞 항을 "어떤 숫자가 몇 개"로 읽은 결과가 다음 항.
문자열을 toCharArray()로 바꿔 앞뒤를 비교하며 개수를 센다.
반복문이 끝난 뒤 마지막 묶음을 붙이는 처리를 반드시 넣어야 한다.
반복 연결이므로 String 대신 StringBuilder를 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
개미수열에서 다음 항을 만드는 규칙을 말로 설명해보세요.
이전 항을 '읽어서' 적습니다.1121은 "1이 2개, 2가 1개, 1이 1개"이므로 이 과제 규칙(숫자 → 개수)으로는 12 21 11 → 122111이 되죠(위 코드의 5번째 항). 같은 숫자가 연속된 구간을 세어 "숫자 + 개수" 형태로 이어 붙이는 것입니다. 표준 Look-and-say는 순서만 반대("개수 + 숫자")라 1211 → 111221처럼 진행합니다.
연속 구간을 세는 로직에서 가장 실수하기 쉬운 지점은?
마지막 구간 처리입니다. 반복문 안에서 "값이 바뀔 때 기록"하는 방식으로 짜면 마지막 그룹은 값이 안 바뀌어 기록되지 않습니다. 반복문이 끝난 뒤 남은 것을 한 번 더 기록해야 합니다.
💼 실무·코딩테스트에서는런렝스 인코딩(RLE)이라는 실제 압축 기법과 같은 원리라, 응용 범위가 넓습니다. 코딩테스트에서 "연속된 것을 묶어 세는" 유형은 자주 나오고, 마지막 그룹 누락이 단골 오답 원인입니다.
TIP같은 로직을 재귀로도 쓸 수 있지만, 이 문제는 반복문이 훨씬 읽기 쉽습니다. 대신 "6번째 항 구하기"처럼 N번 반복하는 부분을 메서드 밖으로 빼면(next()를 N번 호출) 코드가 깔끔해집니다.
08
로또 번호 만들기 (Lotto · LottoStore · 당첨 비교)
Math.random중복 제거배열객체 설계
한 줄 요약1~45 중 중복 없는 6개를 뽑아 배열에 담는 Lotto 클래스를 만들고, 그 객체를 여러 장 보관하는 LottoStore, 추첨번호와 비교해 등수를 매기는 클래스로 확장하는 3단계 객체 설계 문제다.
쉽게 말하면1번은 로또 한 장 만들기, 2번은 로또 판매점 만들기(여러 장 보관), 3번은 추첨 결과 확인하기예요. 지금까지의 문제가 "계산"이었다면 이 문제는 "역할을 나눠 객체로 설계하기"의 첫걸음입니다.
문제 1: Lotto 클래스 — 중복 없는 6개
번호는 1~45 중 6개이고 중복이 없어야 합니다. Math.random()은 0.0 이상 1.0 미만의 실수를 주므로, (int)(Math.random() * 45) + 1로 1~45 정수를 만듭니다. 중복 확인은 ① 이미 뽑은 배열을 반복문으로 훑어 확인하거나 ② Set을 쓰면 자동으로 걸러집니다. 배열로 푸는 것이 과제의 의도(배열에 저장하여 관리)입니다.
문제 2: LottoStore 클래스 — 여러 장 관리
매수(장수)를 받아 Lotto 객체를 담는 배열을 만들고 그 수만큼 저장합니다. 여기서 중요한 것은 배열의 요소 타입이 기본타입이 아니라 객체(Lotto)라는 점입니다 — Lotto[] lottos = new Lotto[count];로 만들면 각 칸은 null이고, new Lotto()로 하나씩 채워야 합니다(16. 배열).
문제 3: 당첨 비교 — 등수 매기기
추첨번호 6개를 따로 생성한 뒤, 구매한 각 로또와 비교해 일치하는 개수를 셉니다. 이중 반복문으로 "내 번호 하나하나가 추첨번호에 있는지" 확인하면 됩니다. 이 과제는 수업용으로 단순화한 규칙(6개 일치 1등, 5개 2등, 4개 3등, 3개 4등, 2개 5등)을 씁니다. 실제 로또 6/45는 보너스 번호가 하나 더 있어서 6개 1등 · 5개+보너스 2등 · 5개 3등 · 4개 4등 · 3개 5등이고, 2개 이하는 낙첨입니다.
객체 설계의 관점
이 문제의 핵심은 "무엇이 무엇을 가지는가"입니다 — LottoStore는 Lotto를 여러 개 가지고, Lotto는 번호 배열을 가집니다. 이런 포함 관계를 코드로 옮기는 연습이 이후 12번 카드(CardDeck이 Card를 가짐)로 이어집니다.
풀이 단계
Lotto: int[] numbers = new int[6] 필드 + 생성자에서 중복 없이 6개 생성 + print() 메서드.
중복 확인: 새로 뽑은 수가 이미 배열에 있는지 확인하는 contains() 메서드를 따로 만든다.
LottoStore: 매수를 받아 Lotto[] 배열을 만들고 반복문으로 new Lotto()를 채운다.
당첨 확인: 추첨 Lotto를 하나 더 만들고, 구매한 각 로또와 일치 개수를 세어 등수를 출력한다.
JAVALotto.java + LottoStore.java + LottoMain.java
import java.util.Arrays;
// ---- 문제 1: 로또 한 장 ----
class Lotto {
private int[] numbers = new int[6];
public Lotto() {
int count = 0;
while (count < 6) {
int n = (int)(Math.random() * 45) + 1; // 1 ~ 45
if (!contains(n, count)) { // 중복이 아니면 저장
numbers[count] = n;
count++;
}
}
Arrays.sort(numbers); // 보기 좋게 정렬
}
// 이미 뽑은 번호인지 확인 (count 개까지만 검사)
private boolean contains(int n, int count) {
for (int i = 0; i < count; i++) {
if (numbers[i] == n) return true;
}
return false;
}
public int[] getNumbers() { return numbers; }
public void print() { System.out.println(Arrays.toString(numbers)); }
// 추첨번호와 비교해 일치 개수를 센다
public int matchCount(Lotto win) {
int match = 0;
for (int my : numbers) {
for (int w : win.getNumbers()) {
if (my == w) { match++; break; }
}
}
return match;
}
}
// ---- 문제 2: 로또 판매점 (여러 장 보관) ----
class LottoStore {
private Lotto[] lottos;
public LottoStore(int count) {
lottos = new Lotto[count]; // 객체 배열 — 아직 전부 null
for (int i = 0; i < count; i++) {
lottos[i] = new Lotto(); // 하나씩 실제 객체로 채운다
}
}
public Lotto[] getLottos() { return lottos; }
public void printAll() { for (Lotto l : lottos) l.print(); }
}
// ---- 문제 3: 당첨 확인 ----
public class LottoMain {
// 수업용 단순 규칙 (실제 로또는 보너스 번호로 2등을 가른다 — 위 설명 참고)
public static String rank(int match) {
switch (match) {
case 6: return "1등";
case 5: return "2등";
case 4: return "3등";
case 3: return "4등";
case 2: return "5등";
default: return "낙첨";
}
}
public static void main(String[] args) {
LottoStore store = new LottoStore(5); // 5장 구매
Lotto win = new Lotto(); // 추첨번호
System.out.print("당첨번호: ");
win.print();
System.out.println("--- 구매한 로또 ---");
for (Lotto l : store.getLottos()) {
int match = l.matchCount(win);
System.out.print(Arrays.toString(l.getNumbers()));
System.out.println(" → 맞은 개수 " + match + "개, " + rank(match));
}
}
}
핵심 정리
(int)(Math.random() * 45) + 1로 1~45 정수를 만든다.
중복 확인은 이미 뽑은 개수(count)까지만 검사하면 충분하다.
객체 배열은 new Lotto[n]으로 만들면 전부 null이므로 하나씩 new로 채워야 한다.
LottoStore가 Lotto를 "가지는" 포함 관계가 이 문제의 설계 포인트다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
로또 번호 생성에서 중복을 막는 방법 두 가지는?
① Set에 넣으며 크기가 6이 될 때까지 반복 ② 1~45를 배열에 담아 섞은 뒤 앞 6개를 취하기(셔플). 두 번째가 중복 검사 없이 한 번에 끝나 더 깔끔하고, 확률적으로도 균등합니다.
클래스를 Lotto와 LottoStore로 나눈 이유는?
책임이 다르기 때문입니다. Lotto는 번호 한 벌을 표현하고, LottoStore는 발급하고 판매하는 행위를 담당하죠. 이렇게 나누면 "번호 형식이 바뀌면 Lotto만, 판매 방식이 바뀌면 Store만" 고치면 됩니다.
💼 실무·코딩테스트에서는클래스를 어떻게 나눌지가 이 과제의 진짜 주제입니다. 실무 설계에서 "이 클래스는 무엇을 책임지는가"를 한 문장으로 말할 수 있어야 좋은 분리입니다(단일 책임 원칙). 말이 길어지면 둘로 나눠야 한다는 신호입니다.
TIP중복 제거를 TreeSet<Integer>로 하면 중복 확인과 정렬이 동시에 해결됩니다(Set 카드). 다만 과제는 "배열에 저장하여 관리"를 요구하므로 배열로 먼저 풀고 Set으로 다시 풀어보는 순서를 권합니다.
09
마방진(Magic Square) — 홀수 · 4의 배수 · 6마방진
2차원 배열추상클래스팩토리알고리즘
한 줄 요약가로·세로·대각선의 합이 모두 같아지도록 숫자를 배치하는 문제로, 홀수·4의 배수·그 밖의 짝수 세 가지 알고리즘이 완전히 다르며 추상 클래스와 팩토리로 구조화하는 것이 과제의 목표다.
쉽게 말하면마방진은 숫자로 만드는 균형 잡힌 격자예요. 어느 줄을 더해도 같은 값이 나와야 합니다. 재미있는 건 격자 크기에 따라 만드는 방법이 완전히 다르다는 것이라, 홀수용·4의 배수용·나머지 짝수용 알고리즘을 각각 배우게 됩니다. 그래서 이 문제가 추상 클래스를 연습하기에 딱 좋습니다.
① 홀수 마방진 (3, 5, 7…)
규칙은 네 줄로 요약됩니다 — ⓐ 시작 위치는 (0행, n/2열)에 1을 놓습니다. ⓑ 다음 수는 현재 위치에서 왼쪽 위 대각선(행-1, 열-1)로 이동해 놓습니다. ⓒ 좌표가 음수가 되면 반대편 끝(최대값)으로 돌아갑니다. ⓓ 이동한 위치에 이미 값이 있으면 이동 전 위치에서 (행+1, 같은 열)로 내려가 놓습니다. 이 네 규칙을 n×n번 반복하면 완성됩니다.
② 4의 배수 마방진 (4, 8, 12…)
1~n²를 순서대로 채운 뒤(magic[i/n][i%n] = i+1, 1차원↔2차원 변환), 특정 위치의 값만 역순 값으로 바꿉니다. 과제는 이를 "0번째 행과 3번째 행, 1번째 열과 2번째 열…" 같은 영역 조건으로 설명하는데, 이는 결국 4×4 블록의 두 대각선 위치를 뒤집는 것과 같습니다. 조건 (i%4 == j%4) || ((i%4 + j%4) == 3)에 해당하면 값을 n*n + 1 - 값으로 바꿉니다.
③ 6마방진 (4의 배수가 아닌 짝수: 6, 10, 14…)
가장 복잡합니다. 격자를 n/2 크기의 4개 영역(A·B·C·D)으로 나누고, ⓐ 각 영역에 n/2 크기의 홀수 마방진을 넣은 뒤 영역마다 0·1·2·3 × (n/2)²를 더합니다(A는 +0, B는 +1배, C는 +2배, D는 +3배). ⓑ 그다음 일부 열을 위아래로 교환합니다 — 왼쪽 k = (n-2)/4개 열은 A↔D를 바꾸되 가운데 행만 한 칸 오른쪽으로 밀고, 오른쪽 k-1개 열은 C↔B를 바꿉니다. 이렇게 4개 영역 + 열 교환으로 만드는 방식을 스트레이치(Strachey) 방법이라고 부릅니다(LUX 방법은 다른 알고리즘입니다).
④ 검증과 클래스 설계
완성 후에는 각 행의 합·각 열의 합·두 대각선의 합을 모두 구해 전부 같으면 true, 하나라도 다르면 false를 반환하는 검증 메서드를 만듭니다. 과제의 클래스 다이어그램은 MagicSquare(추상 클래스: magic 배열 + 합 검증 메서드들) ← OddMagicSquare·EvenMagicSquare·SixMagicSquare가 make()를 각각 구현하고, Interface_Magic(make·print 명세)과 MagicFactory(싱글턴 팩토리)가 어떤 구현체를 만들지 결정하는 구조입니다. 아래 코드는 알고리즘만 보여 줍니다 — 클래스 분리 없이 static 메서드 하나의 파일로 모았고, create()가 팩토리 자리를 대신합니다. 추상 클래스·팩토리로 나눈 수업 코드는 14일차를 참고하세요.
풀이 단계
n을 입력받아 홀수 / 4의 배수 / 그 밖의 짝수 세 갈래로 나눈다.
홀수: 시작 위치 (0, n/2) → 왼쪽 위 대각선 이동 → 음수면 반대편 → 값이 있으면 아래로.
4의 배수: 순서대로 채운 뒤 대각선 패턴 위치의 값을 n*n+1-값으로 반전.
6마방진: n/2 홀수 마방진 4벌 배치(+0·+1·+2·+3배) → 좌우 일부 열 교환(가운데 행은 한 칸 밀기).
행·열·대각선의 합을 모두 구해 같은지 검증한다.
JAVAMagicSquare.java
import java.util.Arrays;
public class MagicSquare {
// ---- ① 홀수 마방진 ----
public static int[][] odd(int n) {
int[][] m = new int[n][n];
int r = 0, c = n / 2; // 시작 위치: 0행, 가운데 열
m[r][c] = 1;
for (int i = 2; i <= n * n; i++) {
int nr = (r - 1 + n) % n; // 왼쪽 위 대각선 (음수면 반대편 끝으로)
int nc = (c - 1 + n) % n;
if (m[nr][nc] != 0) { // 이미 값이 있으면 이동 전 위치에서 한 칸 아래로
nr = (r + 1) % n;
nc = c;
}
m[nr][nc] = i;
r = nr; c = nc;
}
return m;
}
// ---- ② 4의 배수 마방진 ----
public static int[][] doublyEven(int n) {
int[][] m = new int[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
m[i][j] = i * n + j + 1; // 1~n²를 순서대로 채우고
if ((i % 4 == j % 4) || ((i % 4 + j % 4) == 3)) {
m[i][j] = n * n + 1 - m[i][j]; // 대각선 패턴 위치만 역순 값으로
}
}
}
return m;
}
// ---- ③ 6마방진 (4의 배수가 아닌 짝수) ----
public static int[][] singlyEven(int n) {
int half = n / 2;
int[][] sub = odd(half); // n/2 크기의 홀수 마방진을 먼저 만든다
int[][] m = new int[n][n];
int sq = half * half;
for (int i = 0; i < half; i++) {
for (int j = 0; j < half; j++) {
m[i][j] = sub[i][j]; // A 영역 (+0)
m[i + half][j + half] = sub[i][j] + sq; // B 영역 (+1배)
m[i][j + half] = sub[i][j] + 2 * sq; // C 영역 (+2배)
m[i + half][j] = sub[i][j] + 3 * sq; // D 영역 (+3배)
}
}
int k = (n - 2) / 4;
for (int i = 0; i < half; i++) {
for (int j = 0; j < k; j++) { // 왼쪽 k개 열: A ↔ D 교환
int jj = (i == half / 2) ? j + 1 : j; // 가운데 행만 한 칸 오른쪽
int t = m[i][jj]; m[i][jj] = m[i + half][jj]; m[i + half][jj] = t;
}
for (int j = 0; j < k - 1; j++) { // 오른쪽 k-1개 열: C ↔ B 교환
int jj = n - 1 - j;
int t = m[i][jj]; m[i][jj] = m[i + half][jj]; m[i + half][jj] = t;
}
}
return m;
}
// ---- 크기에 맞는 알고리즘 선택 (팩토리 역할) ----
public static int[][] create(int n) {
if (n % 2 == 1) return odd(n);
else if (n % 4 == 0) return doublyEven(n);
else return singlyEven(n);
}
// ---- ④ 검증: 행·열·대각선의 합이 모두 같은가? ----
public static boolean isMagic(int[][] m) {
int n = m.length;
int target = 0, d1 = 0, d2 = 0;
for (int j = 0; j < n; j++) target += m[0][j]; // 첫 행의 합을 기준으로
for (int i = 0; i < n; i++) {
int row = 0, col = 0;
for (int j = 0; j < n; j++) { row += m[i][j]; col += m[j][i]; }
if (row != target || col != target) return false;
d1 += m[i][i]; // 왼쪽 위 → 오른쪽 아래 대각선
d2 += m[i][n - 1 - i]; // 오른쪽 위 → 왼쪽 아래 대각선
}
return d1 == target && d2 == target;
}
public static void print(int[][] m) {
for (int[] row : m) {
for (int v : row) System.out.printf("%4d", v);
System.out.println();
}
}
public static void main(String[] args) {
for (int n : new int[]{3, 4, 6, 10}) {
int[][] m = create(n);
System.out.println("=== " + n + " 마방진 (마방진 확인: " + isMagic(m) + ") ===");
print(m);
}
}
}
핵심 정리
홀수: (0, n/2)에서 시작 → 왼쪽 위 대각선 → 음수면 반대편 → 값이 있으면 아래로.
4의 배수: 순서대로 채운 뒤 대각선 패턴 위치를 n*n+1-값으로 반전.
6마방진: n/2 홀수 마방진 4벌(+0·+1·+2·+3배) 배치 후 좌우 일부 열을 위아래 교환.
검증은 모든 행·열·두 대각선의 합이 같은지 확인한다.
세 알고리즘이 make()라는 같은 이름을 갖도록 추상 클래스로 묶는 것이 설계 목표다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
홀수 마방진의 규칙을 말로 설명해보세요.
첫 행 가운데에서 시작해 왼쪽 위 대각선(행-1, 열-1)으로 이동하며 채웁니다. 범위를 벗어나면 반대편으로 감싸 돌고, 이미 채워져 있으면 이동 전 칸의 바로 아래로 내려갑니다. 이 세 규칙만으로 완성됩니다. ※ 좌우를 뒤집은 오른쪽 위(행-1, 열+1) 이동도 똑같이 마방진이 됩니다(결과가 거울에 비친 모양일 뿐). 책마다 방향이 다르니, 위 코드와 과제는 왼쪽 위 기준으로 기억하면 됩니다.
배열 범위를 벗어날 때 감싸 도는 처리를 어떻게 구현하나?
나머지 연산을 씁니다 — (i - 1 + n) % n처럼요. + n을 더하는 이유는 음수가 되는 것을 막기 위해서입니다. 자바의 %는 음수에 대해 음수를 돌려주므로 이 처리가 필요합니다.
💼 실무·코딩테스트에서는2차원 배열에서 방향 이동과 경계 처리는 코딩테스트의 핵심 기술입니다 — 미로 탐색, 게임판, 회전 문제가 전부 같은 기술을 씁니다. dx/dy 배열로 방향을 정의하는 관용구를 익혀 두면 두루 쓰입니다.
TIP알고리즘이 어렵다면 3×3부터 손으로 그려 보세요. 1을 (0,1)에 놓고 규칙대로 2, 3, 4… 를 채워 나가면 코드가 뭘 하는지 눈에 들어옵니다. 그리고 isMagic() 같은 검증 메서드를 먼저 만들어 두면 알고리즘이 맞는지 즉시 확인할 수 있어 디버깅이 훨씬 쉬워집니다.
10
야구게임(숫자야구) 구현하기
스트라이크볼Scannerwhile
한 줄 요약중복 없는 숫자 3개를 만들어 두고 사용자 입력과 비교해, 숫자와 위치가 모두 같으면 스트라이크·숫자만 같으면 볼로 세어 3스트라이크가 될 때까지 반복한다.
쉽게 말하면숫자야구는 스무고개의 숫자 버전이에요. 정답을 직접 알려주지 않고 "숫자도 위치도 맞았다(스트라이크)", "숫자는 있는데 자리가 틀렸다(볼)", "아예 없다(아웃)"라는 힌트만 줍니다. 그 힌트를 정확히 계산하는 것이 이 문제의 전부입니다.
게임 규칙
투수가 숫자 3개를 처음에 한 번 생성하고, 타자는 맞힐 때까지 계속 3개를 입력합니다. 판정 규칙은 — 숫자와 위치가 모두 일치하면 스트라이크(S), 숫자는 일치하지만 위치가 다르면 볼(B), 모두 불일치하면 아웃(out)입니다. 예: 투수 2 6 9 / 타자 3 2 9 → 2는 있지만 자리가 다르므로 1볼, 9는 자리까지 맞으므로 1스트라이크 → 1S 1B.
판정 로직 — 이중 반복문
내 숫자 i번째와 투수 숫자 j번째를 모두 비교합니다. 값이 같을 때 i == j이면 스트라이크, 아니면 볼로 세면 한 번의 이중 반복으로 둘 다 구할 수 있습니다. 스트라이크가 3이면 승리, 스트라이크·볼이 모두 0이면 아웃입니다.
중복 없는 숫자 생성
야구게임의 숫자도 서로 달라야 합니다(중복이 있으면 판정이 애매해집니다). 로또 문제에서 만든 중복 확인 로직을 그대로 쓸 수 있습니다. 보통 1~9 범위에서 3개를 뽑습니다(0을 포함할지는 규칙에 따라 다릅니다).
입력 처리
Scanner로 세 숫자를 각각 받거나, 한 줄로 "329"를 받아 charAt()으로 쪼갤 수 있습니다. 후자가 입력이 편하며 Character.getNumericValue()나 c - '0'으로 숫자로 변환합니다(06. 형 변환).
풀이 단계
중복 없는 3자리 정답 배열을 만든다(게임 시작 시 한 번).
while (true)로 사용자 입력을 반복해서 받는다.
이중 반복문으로 값이 같은 경우를 찾아, 인덱스도 같으면 strike++ 아니면 ball++.
strike가 3이면 승리 메시지 출력 후 break, 둘 다 0이면 "out" 출력.
시도 횟수를 세어 마지막에 함께 보여 주면 완성도가 올라간다.
JAVABaseballGame.java
import java.util.Scanner;
public class BaseballGame {
// 1~9 중 중복 없는 숫자 3개 생성 (로또 문제의 중복 확인 로직과 동일)
public static int[] makeNumbers() {
int[] arr = new int[3];
int count = 0;
while (count < 3) {
int n = (int)(Math.random() * 9) + 1;
boolean dup = false;
for (int i = 0; i < count; i++) if (arr[i] == n) dup = true;
if (!dup) arr[count++] = n;
}
return arr;
}
public static void main(String[] args) {
int[] pitcher = makeNumbers();
Scanner sc = new Scanner(System.in);
int tryCount = 0;
System.out.println("서로 다른 숫자 3개를 입력하세요 (예: 123)");
while (true) {
System.out.print("타자: ");
String input = sc.next();
if (input.length() != 3) { // 간단한 입력 검증
System.out.println("3자리로 입력하세요.");
continue;
}
tryCount++;
int[] batter = new int[3];
for (int i = 0; i < 3; i++) {
batter[i] = input.charAt(i) - '0'; // 문자 '3' → 숫자 3
}
int strike = 0, ball = 0;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (batter[i] == pitcher[j]) {
if (i == j) strike++; // 숫자도 위치도 일치
else ball++; // 숫자만 일치
}
}
}
if (strike == 3) {
System.out.println("3S 모두 일치!! 타자 승리!! (" + tryCount + "번 만에 성공)");
break;
} else if (strike == 0 && ball == 0) {
System.out.println("out");
} else {
System.out.println(strike + "S " + ball + "B");
}
}
sc.close();
}
}
핵심 정리
값이 같을 때 인덱스가 같으면 스트라이크, 다르면 볼 — 이중 반복 한 번으로 둘 다 센다.
정답 숫자는 게임 시작 시 한 번만 생성하고, 입력은 맞힐 때까지 반복한다.
스트라이크·볼이 모두 0이면 아웃, 스트라이크 3이면 종료.
문자 → 숫자 변환은 charAt(i) - '0'을 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
스트라이크와 볼을 판정하는 조건을 정확히 말해보세요.
숫자와 자리가 모두 같으면 스트라이크, 숫자는 있는데 자리가 다르면 볼입니다. 구현할 때 i == j이면 스트라이크, 아니면 볼로 이중 반복문 안에서 한 번에 판정하면 깔끔합니다.
정답 숫자를 만들 때 중복을 허용하면 왜 안 되나?
같은 숫자가 두 번 있으면 볼 판정이 모호해지기 때문입니다. 3이 두 자리에 있으면 사용자가 3을 하나 냈을 때 볼이 1인지 2인지 정할 수 없죠. 게임 규칙 자체가 서로 다른 숫자를 전제로 합니다.
💼 실무·코딩테스트에서는야구게임은 "규칙을 코드로 정확히 옮기는" 훈련으로 좋습니다. 실무에서도 요구사항을 조건문으로 빠짐없이 번역하는 일이 대부분이고, 여기서 경계 조건과 예외 상황(중복 입력, 잘못된 자릿수)을 챙기는 습관이 만들어집니다.
TIP디버깅할 때는 정답을 미리 출력해 두고 판정 로직만 확인한 뒤 그 줄을 지우세요. 정답을 모르는 채로 판정 버그를 찾으려면 시간이 몇 배로 걸립니다.
11
달력 구현하기 + 살아온 일수 구하기
경과일요일 계산윤년printf
한 줄 요약1년 1월 1일부터 해당 월 1일까지의 총 경과일을 구해 7로 나눈 나머지로 시작 요일(앞쪽 공백 수)을 정하고, 윤년·평년별 월의 마지막 날 배열로 날짜를 채워 출력한다.
쉽게 말하면달력을 그리려면 딱 두 가지만 알면 됩니다 — "1일이 무슨 요일인가"(앞에 빈칸을 몇 개 둘지)와 "이 달이 며칠까지 있는가". 첫 번째는 아주 먼 과거부터 며칠이 지났는지를 세서 7로 나눈 나머지로, 두 번째는 월별 일수 배열로 구합니다.
① 해당 달 1일의 요일 구하기
1년 1월 1일부터 구하려는 달의 1일까지 경과한 총 일수를 구해 % 7을 하면 요일이 나옵니다. 기준을 맞추기 위해 1년 1월 1일이 무슨 요일인지 알아야 합니다. 지금 쓰는 그레고리력을 1년까지 거슬러 적용하면 1년 1월 1일은 월요일이고, 이 날의 경과일(totalDays(1, 1, 1))은 1입니다. 그래서 나머지 1 = 월요일, 2 = 화요일, …, 6 = 토요일, 0 = 일요일로 읽으면 실제 달력과 맞습니다. 경과일은 ① 지난 연도들의 일수(윤년이면 366, 평년이면 365) + ② 올해 지난 달들의 일수 + ③ 1(해당 달 1일)로 계산합니다.
② 월의 마지막 날 구하기
월마다 28·29·30·31일로 다르므로 윤년용·평년용 배열 두 개를 만들어 둡니다 — 평년 {31,28,31,30,31,30,31,31,30,31,30,31}, 윤년은 2월만 29입니다. 윤년 판별은 6번 문제의 메서드를 그대로 씁니다.
③ 출력 형식 맞추기
1일 앞에 시작 요일만큼 공백을 출력하고, 날짜를 하나씩 찍다가 토요일(7의 배수 위치)마다 줄을 바꿉니다. printf("%3d", day)처럼 자리수를 고정하면 열이 정확히 맞습니다(10. printf).
④ 문제 2: 살아온 일수
달력 코드의 경과일 계산 메서드를 그대로 재사용합니다. 오늘까지의 총 경과일 - 생일까지의 총 경과일이 살아온 일수입니다. 여기서도 "메서드로 잘라 두면 다음 문제가 공짜"라는 패턴이 반복됩니다.
풀이 단계
isLeapYear(year)(6번 문제) 준비.
lastDay(year, month) — 윤년/평년 배열에서 그 달의 마지막 날 반환.
totalDays(year, month, day) — 1년 1월 1일부터의 총 경과일 계산.
시작 요일 = totalDays(year, month, 1) % 7.
공백을 시작 요일만큼 출력 → 1일부터 마지막 날까지 출력 → 7개마다 줄바꿈.
살아온 일수 = totalDays(오늘) - totalDays(생일).
JAVACalendar2023.java
import java.time.LocalDate;
public class Calendar2023 {
static final int[] NORMAL = {31,28,31,30,31,30,31,31,30,31,30,31};
static final int[] LEAP = {31,29,31,30,31,30,31,31,30,31,30,31};
// 6번 문제의 윤년 판별을 그대로 재사용
public static boolean isLeapYear(int y) {
return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
}
public static int lastDay(int year, int month) {
return isLeapYear(year) ? LEAP[month - 1] : NORMAL[month - 1];
}
// 1년 1월 1일부터 (year, month, day)까지의 총 경과일
public static int totalDays(int year, int month, int day) {
int total = 0;
for (int y = 1; y < year; y++) { // ① 지난 연도들
total += isLeapYear(y) ? 366 : 365;
}
for (int m = 1; m < month; m++) { // ② 올해 지난 달들
total += lastDay(year, m);
}
return total + day; // ③ 이번 달의 날짜
}
public static void printMonth(int year, int month) {
System.out.printf("%n < %d년 %d월 >%n", year, month);
System.out.println(" 일 월 화 수 목 금 토");
int start = totalDays(year, month, 1) % 7; // 0이면 일요일
for (int i = 0; i < start; i++) System.out.print(" "); // 앞 공백
int last = lastDay(year, month);
for (int d = 1; d <= last; d++) {
System.out.printf("%3d ", d);
if ((start + d) % 7 == 0) System.out.println(); // 토요일마다 줄바꿈
}
System.out.println();
}
public static void main(String[] args) {
// 문제 1: 2023년 1월 ~ 12월 달력 출력
for (int m = 1; m <= 12; m++) printMonth(2023, m);
// 문제 2: 살아온 일수 (생일을 입력하면 오늘까지의 일수를 계산)
int by = 2000, bm = 5, bd = 15; // 예시 생일
LocalDate today = LocalDate.now();
int days = totalDays(today.getYear(), today.getMonthValue(), today.getDayOfMonth())
- totalDays(by, bm, bd);
System.out.println("살아온 일수: " + days + "일");
}
}
핵심 정리
시작 요일 = (1년 1월 1일부터의 총 경과일) % 7 — 1년 1월 1일(월요일)의 경과일이 1이므로 1=월 … 0=일.
월별 마지막 날은 윤년/평년 배열 두 개로 관리한다.
출력은 시작 요일만큼 공백 → 날짜 출력 → 7개마다 줄바꿈.
살아온 일수 = 오늘까지의 경과일 - 생일까지의 경과일(같은 메서드 재사용).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
특정 날짜의 요일을 구하려면 무엇이 필요한가?
기준일과 그 요일, 그리고 기준일부터 며칠 지났는지입니다. 총 일수를 7로 나눈 나머지가 요일이 되죠. 이때 윤년을 정확히 세는 것이 관건이라, 윤년 판별(prac-06)이 여기서 다시 쓰입니다.
달력 출력에서 첫 줄 들여쓰기를 어떻게 계산하나?
그 달 1일의 요일만큼 빈칸을 출력합니다. 1일이 수요일이면 일·월·화 자리 3칸을 비우죠. 그다음부터는 7의 배수마다 줄바꿈하면 됩니다.
💼 실무·코딩테스트에서는달력은 여러 개념이 합쳐지는 종합 과제입니다 — 윤년, 나머지 연산, 서식 출력이 한 번에 나오죠. 실무에서 날짜는 반드시 라이브러리를 쓰지만, 직접 만들어 본 경험이 있어야 라이브러리가 무엇을 대신해 주는지 알 수 있습니다.
TIP1년부터 반복해 경과일을 세면 계산량이 많지만 이해하기 쉽고 정확합니다. 성능이 신경 쓰인다면 (year-1)*365 + 윤년 개수 공식으로 한 번에 계산할 수도 있습니다(윤년 개수 = (y-1)/4 - (y-1)/100 + (y-1)/400).
12
카드 만들기 (Card · CardDeck)
배열ArrayList중복 방지객체 설계
한 줄 요약무늬 배열과 숫자 배열에서 하나씩 골라 카드 한 장을 만드는 Card 클래스와, 중복 없이 52장을 생성해 보관하는 CardDeck 클래스를 설계하는 문제다.
쉽게 말하면카드 한 장은 "무늬 + 숫자"의 조합이에요(♥ + Q = ♥Q). 무늬 4종 × 숫자 13종 = 52장이고, 덱은 그 52장을 중복 없이 모두 담은 상자입니다. 로또 문제와 구조가 똑같습니다 — 한 장(Lotto/Card)과 여러 장을 담는 상자(LottoStore/CardDeck).
문제 1: Card 클래스
필요한 것은 ① 무늬를 저장할 배열(♥ ♣ ♠ ◆) ② 숫자를 저장할 배열("A", "2"~"10", "J", "Q", "K" — 13종) ③ 랜덤 숫자를 생성하는 기능입니다. 무늬 인덱스와 숫자 인덱스를 각각 랜덤으로 골라 조합하면 카드 한 장이 됩니다. 무늬·숫자 배열은 모든 카드가 공유하므로 static final로 두는 것이 자연스럽습니다(10. final과 상수).
문제 2: CardDeck 클래스
52번 실행해 52장을 만들되 중복이 없어야 합니다. 방법은 두 가지 — ① 랜덤 + 중복 확인(로또와 같은 방식): 이미 있는 카드면 다시 뽑습니다. 52장 중 마지막 몇 장은 뽑힐 확률이 낮아 반복이 많아진다는 단점이 있습니다. ② 전부 만들고 섞기: 4×13 이중 반복으로 52장을 순서대로 만든 뒤 Collections.shuffle()로 섞습니다 — 훨씬 효율적이고 실제 카드 덱과 같은 방식입니다.
ArrayList를 쓰는 이유
과제 그림에도 "ArrayList 52칸이 만들어짐"이라고 되어 있습니다. 배열은 크기가 고정이라 카드를 뽑아 없앨 때 불편하지만, ArrayList는 remove()로 꺼내면 크기가 자동으로 줄어 "덱에서 카드를 한 장 뽑는다"는 동작을 자연스럽게 표현할 수 있습니다.
중복 판단을 위한 equals
Card 객체의 중복을 list.contains(card)로 확인하려면 equals()를 오버라이드해야 합니다(02. Object 4대 메서드). 오버라이드하지 않으면 주소 비교가 되어 내용이 같은 카드도 다른 것으로 판단됩니다. 아래 코드는 이 함정을 피하기 위해 전부 만들고 섞는 방식을 기본으로 하고, 랜덤 방식도 함께 담았습니다.
CardDeck: ArrayList<Card> 필드 + 생성자에서 4×13 이중 반복으로 52장 생성.
Collections.shuffle(list)로 섞고, draw()로 한 장씩 뽑는다.
중복 검사 방식으로 만들 거라면 Card에 equals()·hashCode()를 오버라이드한다.
JAVACard.java + CardDeck.java + CardMain.java
import java.util.*;
// ---- 문제 1: 카드 한 장 ----
class Card {
// 모든 카드가 공유하는 값이므로 static final (상수)
public static final String[] SHAPES = {"♥", "♣", "♠", "◆"};
public static final String[] NUMBERS =
{"A","2","3","4","5","6","7","8","9","10","J","Q","K"}; // 13종 (수업 D2_Card의 STECK과 같은 구성)
private String shape;
private String number;
// 랜덤 카드 한 장
public Card() {
this(SHAPES[(int)(Math.random() * SHAPES.length)],
NUMBERS[(int)(Math.random() * NUMBERS.length)]);
}
// 지정 카드 (덱을 순서대로 만들 때 사용)
public Card(String shape, String number) {
this.shape = shape;
this.number = number;
}
@Override
public String toString() { return shape + number; }
// 중복 확인(contains)을 쓰려면 equals·hashCode가 필요하다
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Card)) return false;
Card c = (Card) o;
return shape.equals(c.shape) && number.equals(c.number);
}
@Override
public int hashCode() { return Objects.hash(shape, number); }
}
// ---- 문제 2: 카드 52장이 들어있는 덱 ----
class CardDeck {
private List<Card> cards = new ArrayList<>();
// 방법 ②: 4 × 13 으로 전부 만들고 섞는다 (중복이 원천적으로 없다)
public CardDeck() {
for (String s : Card.SHAPES) {
for (String n : Card.NUMBERS) {
cards.add(new Card(s, n));
}
}
Collections.shuffle(cards);
}
// 방법 ①: 랜덤으로 뽑되 중복이면 다시 (equals 오버라이드가 필요하다)
public static CardDeck byRandom() {
CardDeck deck = new CardDeck(); // 생성자가 52장을 만들어 섞어 두지만
deck.cards.clear(); // 랜덤 방식을 보여 주려고 일부러 비우고 처음부터 다시 채운다
// (더 깔끔하게는 빈 덱을 만드는 생성자를 따로 두면 된다)
while (deck.cards.size() < 52) {
Card c = new Card();
if (!deck.cards.contains(c)) deck.cards.add(c); // contains → equals 사용
}
return deck;
}
public int size() { return cards.size(); }
public Card draw() { // 맨 위 카드 한 장 뽑기
return cards.isEmpty() ? null : cards.remove(0);
}
public void printAll() { System.out.println(cards); }
}
public class CardMain {
public static void main(String[] args) {
CardDeck deck = new CardDeck();
System.out.println("덱 크기: " + deck.size()); // 52
deck.printAll();
System.out.println("--- 5장 뽑기 ---");
for (int i = 0; i < 5; i++) System.out.print(deck.draw() + " ");
System.out.println("\n남은 카드: " + deck.size()); // 47
}
}
핵심 정리
Card = 무늬 배열 + 숫자 배열에서 하나씩 골라 만든 조합(4 × 13 = 52).
모든 카드가 공유하는 무늬·숫자 배열은 static final로 둔다.
52장 만들기는 전부 만들고 Collections.shuffle()이 랜덤+중복확인보다 효율적이다.
ArrayList를 쓰면 remove(0)으로 "한 장 뽑기"를 자연스럽게 표현할 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
Card와 CardDeck을 나눈 것이 로또 과제와 어떤 점에서 같은가?
"하나"와 "그것들의 묶음·관리"를 분리했다는 점이 같습니다. Card는 무늬와 숫자를 가진 한 장, CardDeck은 52장을 만들고 섞고 나눠 주는 책임이죠. 이 패턴은 실무에서 Entity와 Repository·Service의 관계로 그대로 이어집니다.
카드를 섞는(shuffle) 좋은 방법은?
Fisher-Yates 셔플이 정석입니다 — 뒤에서부터 앞으로 가며 남은 구간에서 무작위로 하나 골라 현재 위치와 교환합니다. Collections.shuffle()이 내부적으로 이 방식을 씁니다. 무작위로 두 장을 계속 바꾸는 방식은 균등하지 않을 수 있습니다.
💼 실무·코딩테스트에서는카드 덱은 객체지향 설계의 좋은 연습입니다 — enum으로 무늬를 정의하고, 불변 객체로 카드를 만들고, 덱이 상태를 관리하는 구조는 실무 도메인 모델링과 같은 사고방식입니다. 면접 과제로도 자주 나오는 주제입니다.
TIP이 문제까지 오면 8번(로또) → 12번(카드)이 같은 설계 패턴이라는 게 보입니다 — "하나(Lotto/Card)"와 "여럿을 담는 것(LottoStore/CardDeck)"을 분리하는 구조입니다. 이 감각이 이후 실무의 Entity/Repository 구조로 이어집니다.
🧩 코딩테스트 오답노트 — 프로그래머스 정기 테스트
학원에서 프로그래머스 사이트로 정기 실시하는 코딩테스트를 회차별로 복기한 오답노트입니다. 보통 JAVA(프로그래밍) 문제와 SQL 문제를 함께 풀고(17회차부터는 각 2문제), 제출한 코드 → 정답 풀이 → 주요 개념 순서로 정리했습니다. 수업 진도 카드가 "그날 배운 것"이라면, 이 탭은 "그걸로 실전 문제를 풀어 본 기록"입니다.
1회차
저주의 숫자3
JAVA2026.08.11
한 줄 요약숫자를 1씩 늘리다 3의 배수이거나 3이 들어간 수는 while로 계속 건너뛰고, 3 포함 여부는 숫자를 문자열로 바꿔 contains로 검사한다.
쉽게 말하면엘리베이터에서 불길한 층 번호를 빼듯 3이 걸리는 수는 건너뛰고 다음 수로 넘어간다. 13·23처럼 숨은 3은 숫자를 글자로 바꿔 보면 한 번에 찾는다.
3의 배수이거나 숫자 3이 들어간 수는 모두 건너뛰고 셀 때, n번째로 오는 수를 구하는 문제.
JAVA제출 코드
class Solution {
public int solution(int n){
int answer = 0;
for (int i = 1; i <= n; i++){
answer++;
while (answer % 3 == 0 || (answer+"").contains("3")){
answer++;
}
}
return answer;
}
}
💡 정답 풀이
answer 변수를 만들어 n까지 반복해가며 1씩 증가시키다가, 3으로 나눠지거나, 3이 포함되었을 경우에는 계속 1을 더하게 해서 3이 포함되지 않을때까지 만든다.
이후에 리턴하여준다.
Int로 할때는 자리수별로 3인지 아닌지를 10으로 나누어서 나머지로 판단하는 해답도 있었다.
📌 주요 개념 정리
자바에서 + 연산의 피연산자 하나가 String이면 나머지도 String으로 변환되어 이어 붙여진다.
String.contains() 는 문자열 안에 특정 문자열이 있는지 검사해서 boolean으로 알려준다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
3의 배수를 건너뛰는 조건에서 '3이 포함된 수'는 왜 문자열로 검사했나?
13, 23, 31처럼 자릿수 어디에든 3이 있으면 걸러야 하는데, 숫자 연산만으로는 모든 자릿수를 돌며 확인해야 합니다. 문자열로 바꾸면 contains("3") 한 번으로 끝나죠. 숫자를 문자열로 보는 발상이 이 문제의 핵심입니다.
숫자 뒤에 빈 문자열을 더하면(n + "") 문자열이 되는 이유는?
자바에서 +의 한쪽이 String이면 나머지도 String으로 변환되기 때문입니다. 정식으로는 String.valueOf(n)이 맞지만, n + ""는 짧아서 코딩테스트에서 자주 쓰입니다.
💼 이 유형을 다시 만나면"숫자를 문자열로 바꿔 자릿수를 다루는" 발상은 코딩테스트에서 반복해서 쓰입니다. 반대로 문자열을 숫자로 바꿔 계산해야 하는 문제도 많으니, 두 방향 변환을 자유롭게 오갈 수 있어야 합니다.
1회차
인기있는 아이스크림
SQL2026.08.11
한 줄 요약ORDER BY에 총주문량 내림차순을 1순위, 출하 번호 오름차순을 2순위로 나열해 동점일 때의 순서까지 정한다.
쉽게 말하면달리기 기록이 같으면 등번호 순으로 줄 세우는 것과 같다. 첫 기준이 같을 때만 두 번째 기준이 나선다.
두 정수를 문자열로 이어 붙인 값과 2 × a × b 중 더 큰 쪽을 반환하는 문제. 두 값이 같으면 그 값을 그대로 반환.
JAVA제출 코드
class Solution {
public int solution(int a, int b) {
String aa = a+""+b;
int num1 = Integer.parseInt(aa);
int num2 = 2*a*b;
if(num1>num2){
return num1;
}
else return num2;
}
}
💡 정답 풀이
String변수를 만들어 Java에서는 문자열을 연산하면 문자열이 되는 점을 사용하여 두 숫자를 문자열형으로 이어붙였습니다.
그 뒤 int변수에 parseInt를 사용하여 int형으로 바꿔 넣었습니다
Int로 계산한 num2와 서로 비교하여 큰 숫자를 리턴하였습니다.
📌 주요 개념 정리
문자열 결합 (a + "" + b)
Java에서는 숫자에 빈 문자열("")을 더하면 전체가 String 타입으로 자동 형변환되어 이어 붙여집니다.
문자열 -> 정수 변환 (Integer.parseInt())
결합된 String 형태의 숫자를 산술 연산 및 크기 비교가 가능하도록 int 타입으로 다시 변환합니다.
값 비교 및 반환
a ⊕ b의 결과(num1)와 2 * a * b의 결과(num2)를 비교하여 더 큰 값을 반환합니다.
(⊕는 이 문제에서 정의한 기호로, 두 수를 이어 붙이는 연산이다. 예: 12 ⊕ 3 = 123)
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
두 정수를 '이어 붙인 값'과 '2×a×b'를 비교하려면 무엇이 필요한가?
이어 붙이기는 문자열 연산, 곱셈은 숫자 연산이라 타입을 맞춰야 합니다. Integer.parseInt(a + "" + b)로 다시 숫자로 바꾼 뒤 비교하죠. 한쪽을 다른 쪽 세계로 옮기는 것이 요령입니다.
두 값이 같을 때 그 값을 반환하라는 조건을 어떻게 처리하나?
제출 코드도 이미 처리하고 있습니다 — num1 > num2가 아니면(같을 때 포함) else로 가서 num2를 반환하는데, 같을 때는 num2가 곧 그 값이니까요. 이 if/else 전체를 return Math.max(num1, num2); 한 줄로 바꿔도 같은 결과입니다. 어느 쪽이든 굳이 if (num1 == num2) 분기를 따로 만들 필요가 없습니다. 조건을 하나로 합칠 수 있는지 먼저 살피는 습관이 코드를 짧게 만듭니다.
💼 이 유형을 다시 만나면문제 조건을 그대로 if로 옮기기 전에 "합칠 수 있나"를 한 번 생각하면 코드가 훨씬 간결해집니다. 분기가 적을수록 실수도 줄고, 리뷰에서도 좋은 평가를 받습니다.
2회차
조건에 맞는 도서 리스트 출력하기
SQL2026.08.13
한 줄 요약% 와일드카드는 = 가 아니라 LIKE와 함께 써야 하고, YYYY-MM-DD 출력은 DATE_FORMAT으로 맞춘 뒤 AS로 원래 컬럼명을 다시 붙여야 한다.
쉽게 말하면= 는 출판일이 글자 그대로 2021%인 책만 찾는다. 2021로 시작하는 책을 찾으려면 와일드카드를 알아듣는 LIKE에게 맡겨야 한다.
도서 테이블에서 카테고리가 '인문'이면서 2021년에 출판된 책의 ID와 출판일을 YYYY-MM-DD 형식으로 조회하는 문제.
SQL제출 코드
SELECT BOOK_ID, PUBLISHED_DATE
FROM BOOK
WHERE CATEGORY = '인문' AND PUBLISHED_DATE = '2021%'
ORDER BY PUBLISHED_DATE;
💡 정답 풀이
%와일드카드는 = 이 아니라 LIKE를 사용하여야 합니다. 또한 PUBLISHED_DATE컬럼을 그대로 조회하면 DB설정에 따라 시/분/초 까지 출력될수도 있습니다. 이에 문제 조건에서 YYYY-MM-DD 형식 출력 을 정석적으로 맞추기 위해서는 DATE_FORMAT() 함수를 사용하여야 하며 AS별칭으로 PUBLISHED_DATE 까지 설정해주어야 합니다.
SQL정답 코드
SELECT
BOOK_ID,
DATE_FORMAT(PUBLISHED_DATE, '%Y-%m-%d') AS PUBLISHED_DATE
FROM BOOK
WHERE CATEGORY = '인문'
AND PUBLISHED_DATE LIKE '2021%'
ORDER BY PUBLISHED_DATE ASC;
📌 주요 개념 정리
LIKE 연산자와 와일드카드(%)
부분 문자열/패턴을 검색할 때는 = 대신 LIKE 연산자를 사용합니다.
패턴 검색 시에는 반드시 작은따옴표로 감싸야 문법 오류가 발생하지 않습니다. (예: LIKE '2021%')
날짜 포맷 변환 (DATE_FORMAT)
날짜 타입을 YYYY-MM-DD 형식으로 출력하려면 DATE_FORMAT(컬럼, '%Y-%m-%d') 함수를 사용합니다.
%Y는 4자리 연도, %m은 2자리 월, %d는 2자리 일을 나타냅니다.
컬럼 별칭 지정 (AS)
함수를 사용하면 결과 컬럼 이름이 함수 표현식 자체로 변경됩니다.
문제 요구사항에 맞는 컬럼명을 유지하기 위해 AS PUBLISHED_DATE 처럼 별칭을 지정해야 합니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
LIKE '2021%' 대신 = '2021%' 를 쓰면 왜 결과가 0행인가?
=는 정확히 일치를 뜻하므로 %를 특수 기호가 아니라 그냥 글자로 봅니다. 즉 "값이 문자 그대로 2021%인 행"을 찾게 되죠. 와일드카드는 LIKE와 함께일 때만 의미를 갖습니다.
DATE_FORMAT을 쓴 뒤 AS로 별칭을 다시 붙여야 하는 이유는?
함수를 씌우면 결과 컬럼명이 함수식 자체가 되기 때문입니다 — DATE_FORMAT(PUBLISHED_DATE, '%Y-%m-%d')가 그대로 머리글이 되죠. 문제가 요구하는 컬럼명을 맞추려면 AS PUBLISHED_DATE로 되돌려야 합니다.
💼 이 유형을 다시 만나면코딩테스트 SQL은 컬럼명까지 정확히 일치해야 통과합니다. 함수를 쓰면 반드시 별칭을 확인하세요. 날짜 형식 요구(YYYY-MM-DD)도 흔하니 DATE_FORMAT은 손에 익혀 두면 좋습니다.
3회차
하샤드 수
JAVA2026.08.18
한 줄 요약% 10과 / 10으로 자릿수 합을 구하되, 원본 x는 따로 보관해 두었다가 마지막에 x를 그 합으로 나눈 나머지가 0인지 판별한다.
쉽게 말하면케이크를 한 조각씩 떼어 세다 보면 케이크가 사라진다. 처음 모양은 복사본으로 남겨 둬야 마지막에 비교할 수 있다.
어떤 양의 정수가 자기 각 자리 숫자의 합으로 나누어떨어지는지(하샤드 수인지) 판별해 true / false를 반환하는 문제.
JAVA제출 코드
class Solution {
public boolean solution(int x) {
int num1 = x;
int sum = 0;
while(num1 != 0){
sum = sum + (num1%10);
num1 = num1/10;
}
if((x % sum) == 0){
return true;
}
else return false;
}
}
💡 정답 풀이
자릿수를 10으로 잘라가며 더해나가기 위해 변수를 하나 만든다.
자릿수를 전부 더한값을 저장하기 위해 sum변수를 만든다.
자릿수를 잘라가는 변수가 0이 아닐때까지만 반복하며,
10으로 자리수를 잘라가며 더하고 저장시킨다.
While문을 빠져나와 하샤드 수인지 검사한다.
결과값을 리턴해준다.
📌 주요 개념 정리
하샤드 수의 정의
양의 정수 x가 자신의 각 자릿수 합으로 나누어떨어지는 수
판별 조건: x % (각 자릿수의 합) == 0
- 자릿수를 분리하는 과정에서 숫자가 0으로 줄어들므로,
최종 나눗셈 판별을 위해 원본 x를 별도 변수에 복사해 둠.
- num % 10 : 마지막 일의 자리 추출 후 sum에 누적
- num / 10 : 일의 자리를 제거하여 다음 자릿수로 이동
- num > 0 동안 반복
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
각 자릿수 합을 구할 때 원본 x를 따로 보관해야 하는 이유는?
자릿수를 분해하는 과정에서 값이 0으로 줄어들기 때문입니다. 마지막에 x % sum으로 판별해야 하는데 x가 이미 0이 되어 있으면 계산할 수 없죠. 원본을 건드리는 계산은 복사본으로 하는 것이 원칙입니다.
제출 코드의 while 조건 num1 != 0 을 num1 >= 0 으로 바꾸면?
무한 루프가 됩니다. num1이 0이 된 뒤에도 0 / 10 = 0이라 영원히 0으로 남아 조건이 계속 참이니까요. 제출 코드의 num1 != 0은 양수만 들어오는 이 문제에서 num1 > 0과 같은 뜻이고, 둘 다 0이 되면 멈추는 올바른 탈출 조건입니다.
💼 이 유형을 다시 만나면자릿수 분해(% 10, / 10)는 코딩테스트의 기본 도구입니다. 하샤드 수, 자릿수 합, 숫자 뒤집기가 전부 같은 패턴이죠. 원본 보존과 탈출 조건 두 가지만 챙기면 실수가 거의 없습니다.
1회차
가장 큰 물고기 10마리 구하기
SQL2026.08.11
한 줄 요약NULL 제외는 IS NOT NULL로, 상위 10개는 정렬 뒤 LIMIT 10으로 해야 하며, WHERE LENGTH처럼 덜 쓴 조건은 의도와 다르게 동작한다.
쉽게 말하면키 큰 순으로 줄 세운 뒤 앞에서 10명만 자르는 것이 LIMIT이다. 키를 안 잰 사람(NULL)은 줄 세우기 전에 IS NOT NULL로 빼 둔다.
물고기 정보 테이블에서 길이가 긴 순으로 상위 10마리의 ID와 길이를 조회하는 문제. 길이 미기재(NULL)는 제외.
기록 주의. 이 카드는 1회차(2026.08.11)로 기록되어 있지만 목록에서는 3회차 뒤에 놓여 있다. 이 시기의 원본 기록은 저장소에 없어 확인하지 못했으므로 회차·날짜를 고치지 않았다 — 원본 기록과 어긋날 수 있다.
SQL제출 코드
SELECT ID, LENGTH
FROM FISH_INFO
WHERE LENGTH
ORDER BY LENGTH DESC, ID ASC;
💡 정답 풀이
제출 코드에는 LIMIT 10이 없어서 10마리가 아니라 조건에 맞는 전체 행이 출력됐다 — 이것이 FAIL의 원인이다. WHERE LENGTH는 NULL 행도 걸러 내긴 하지만 길이 0까지 빼는 불완전한 조건이므로 IS NOT NULL로 의도를 분명히 쓴다.
SQL정답 코드
SELECT ID, LENGTH
FROM FISH_INFO
WHERE LENGTH IS NOT NULL
ORDER BY LENGTH DESC, ID ASC
LIMIT 10;
NULL을 제외하기 위해 WHERE에서 IS NOT NULL 로 제외시킨다.
1차기준 LENGTH DESC 다음에 2차기준으로 ID ASC설정한다.
LIMIT 으로 10개만 출력시켜준다.
📌 주요 개념 정리
다중 컬럼 정렬 (ORDER BY col1, col2)
여러 조건으로 정렬할 때는 쉼표(,)로 구분하여 나열
앞에 적힌 컬럼이 우선순위를 가진다.
상위 N개 데이터 추출 (LIMIT)
전체 데이터를 원하는 정렬 기준으로 줄 세운 뒤, 필요한 개수만큼만 가져올 때 사용
ORDER BY와 짝을 이루어 사용되는 경우가 많다.
문법 구조: LIMIT [가져올 행의 개수] (MySQL 기준)
NULL 처리 (IS NOT NULL)
SQL에서 NULL은 값이 없는 특수 상태이므로 = NULL, != NULL 같은 비교 연산자로 비교할 수 없다.
반드시 IS NULL (NULL인 것) 또는 IS NOT NULL (NULL이 아닌 것)을 사용해야 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
WHERE LENGTH 처럼 조건식을 완성하지 않으면 어떻게 되나?
MySQL은 0이 아닌 값을 참으로 보므로, WHERE LENGTH는 "LENGTH가 0이 아닌 행"이라는 뜻이 됩니다. NULL 행은 조건 결과가 NULL(참이 아님)이라 WHERE에서 함께 빠지므로 NULL 제외는 우연히 됩니다. 다만 길이가 0인 물고기까지 빠지는 불완전한 조건이라 IS NOT NULL로 써야 의도가 정확합니다. 이 문제의 실제 FAIL 원인은 이 조건이 아니라 LIMIT 10을 빠뜨린 것입니다.
NULL을 제외하려면 왜 IS NOT NULL을 써야 하나?
NULL은 어떤 비교 연산자로도 판별되지 않기 때문입니다. <> NULL도 = NULL도 전부 UNKNOWN이 되어 참이 되지 못하죠. NULL 전용 문법인 IS NULL·IS NOT NULL만 통합니다.
💼 이 유형을 다시 만나면"상위 N개" 문제는 ORDER BY ... LIMIT N이 정석이고, NULL 제외 조건이 함께 붙는 경우가 많습니다. 두 가지를 세트로 기억해 두면 이 유형은 빠르게 풉니다.
4회차
행렬의 덧셈
JAVA2026.08.20
한 줄 요약결과 배열을 {}로 두면 길이 0이라 값을 넣을 수 없으므로 new int[arr1.length][arr1[0].length]로 크기를 먼저 잡아야 한다.
쉽게 말하면물건을 담으려면 칸이 있는 상자부터 마련해야 한다. 크기 0짜리 빈 상자에는 아무것도 넣을 수 없다.
리턴해주는 배열도 크기 설정 해줘야하는데 안해줘서 오류가 났다(int[][] answer = {};는 길이 0).
입력되는 배열의 크기는 똑같다고 했으니 그냥 둘중 하나의 길이로 썼다.
JAVA정답 코드 (고친 한 줄)
int[][] answer = new int[arr1.length][arr1[0].length];
결과 배열 크기 할당: arr1.length는 행의 개수이고 arr1[0].length는 열의 개수이다.
이를 이용해 answer 배열의 공간을 미리 설정한다.
외부 반복문:각 행을 순회한다
내부 반복문: 각 열을 순회한다.
값 더하기: answer[i][j] = arr1[i][j] + arr2[i][j] 연산으로 같은 위치의 원소를 더해 저장한 뒤 반환한다.
📌 주요 개념 정리
자바 2차원 배열의 크기 확인 방법
배열명.length: 전체 행의 개수
배열명[i].length: i번째 행에 들어있는 열의 개수
배열 공간 생성의 필요성
자바의 배열은 크기가 고정되어 있으므로, 빈 배열로 두면 값을 넣을 때 인덱스 범위를 벗어나는 에러가 발생한다. 반드시 new int[행][열] 형태로 공간을 먼저 만들어야 한다
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
결과 배열의 크기를 미리 정해야 하는 이유는?
자바 배열은 크기가 고정이라 생성할 때 크기를 알려 줘야 하기 때문입니다. int[][] answer = {}는 길이 0짜리 배열이라 어디에도 값을 넣을 수 없죠. new int[행][열]로 공간을 먼저 확보해야 합니다.
2차원 배열에서 행 수와 열 수를 각각 어떻게 구하나?
행 수는 arr.length, 열 수는 arr[0].length입니다. 배열의 배열 구조라 바깥 배열의 길이가 행, 안쪽 배열의 길이가 열이 되죠. 행마다 열 수가 다를 수도 있다는 점도 기억해 두세요.
💼 이 유형을 다시 만나면행렬 문제는 배열 크기 초기화와 행·열 혼동이 오답의 대부분입니다. 문제를 풀기 전에 arr.length와 arr[0].length를 한 번 출력해 확인하는 것만으로 상당수 실수를 막을 수 있습니다.
4회차
가격대 별 상품 개수 구하기
SQL2026.08.20
한 줄 요약TRUNCATE(PRICE, -4)나 1만으로 나눈 몫×10000으로 만 원 단위 구간값을 만들고, 그 값으로 GROUP BY해 COUNT(*)로 센다.
쉽게 말하면가격표의 천 원 이하 자리를 지워 0원대·1만 원대·2만 원대 상자에 나눠 담고, 상자마다 몇 개 들었는지 세는 문제다.
상품 테이블의 가격을 만 원 단위 구간으로 묶고, 각 가격대에 속한 상품이 몇 개인지 세는 문제.
SQL제출 코드
SELECT ?? AS PRICE_GROUP, PRODUCT_ID AS PRODUCTS
FROM PRODUCT
GROUP BY PRICE
HAVING ?;
💡 정답 풀이
제출 코드는 만 원 단위 구간값을 만드는 식을 찾지 못했고(??), 원래 가격(PRICE)으로 묶은 뒤 HAVING으로 새 컬럼을 만들려 했다. 구간값은 SELECT·GROUP BY에서 수식으로 만들고, 개수는 COUNT(*)로 센다(HAVING은 묶인 결과를 거르는 절이다).
SQL정답 코드 1 — TRUNCATE 함수 사용
SELECT TRUNCATE(PRICE, -4) AS PRICE_GROUP,
COUNT(*) AS PRODUCTS
FROM PRODUCT
GROUP BY TRUNCATE(PRICE, -4)
ORDER BY PRICE_GROUP ASC;
SQL정답 코드 2 — 나눗셈 연산 사용
SELECT (PRICE DIV 10000) * 10000 AS PRICE_GROUP,
COUNT(*) AS PRODUCTS
FROM PRODUCT
GROUP BY PRICE_GROUP
ORDER BY PRICE_GROUP ASC;
-- (오라클일 경우)
SELECT FLOOR(PRICE / 10000) * 10000 AS PRICE_GROUP, COUNT(*) AS PRODUCTS
FROM PRODUCT
GROUP BY FLOOR(PRICE / 10000) * 10000
ORDER BY PRICE_GROUP ASC;
1. 만원 단위 가격대 생성:
만원 단위 미만의 금액을 버려서 0, 10000, 20000 등의 최소 구간값으로 변환해야 한다.
TRUNCATE(PRICE, -4)를 사용하면 1의 자리부터 천의 자리까지 4자리를 0으로 버림 처리할 수 있다.
또는 (PRICE DIV 10000) * 10000 처럼 10000으로 나눈 몫을 구한 뒤 다시 10000을 곱해 구간값을 만들 수도 있다.
2. 그룹화 및 개수 집계:
가공된 가격대 컬럼을 기준으로 GROUP BY를 묶고, COUNT(*)를 통해 각 가격대에 속한 상품 수를 계산한다.
3. 정렬:
ORDER BY PRICE_GROUP ASC를 사용하여 가격대 기준으로 오름차순 정렬한다.
📌 주요 개념 정리
1. TRUNCATE(숫자, 자릿수) 함수:
숫자의 특정 자릿수 이하를 버리는 내장 단일행 함수다.
자릿수가 음수일 경우 정수부의 뒷자리를 0으로 만든다. (예: -4는 천의 자리까지 0 처리)
참고로 DDL 문법인 TRUNCATE TABLE(테이블 전체 데이터 삭제)과는 이름만 같은 다른 기능
2. GROUP BY 절:
지정한 컬럼 또는 수식의 결과가 같은 행들을 하나의 그룹으로 묶어주는 구문.
그룹화된 상태에서는 COUNT, SUM, AVG 등의 집계 함수를 통해 그룹별 계산 결과를 출력한다.
3. HAVING 절의 역할:
GROUP BY로 묶인 집계 결과에 대해 조건을 걸어 필터링할 때 사용하는 절.
새로운 컬럼을 가공하거나 별칭을 생성하는 용도로는 사용할 수 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
가격을 만원 단위로 묶는 두 가지 방법을 말해보세요.
① TRUNCATE(PRICE, -4) — 천의 자리까지 버림 ② (PRICE DIV 10000) * 10000 — 나눈 몫에 다시 곱하기. 오라클에서는 FLOOR(PRICE/10000)*10000을 씁니다. 발상은 셋 다 같습니다 — 하위 자리를 없애는 것.
HAVING으로 새 컬럼을 만들려 한 것이 왜 잘못인가?
HAVING은 이미 묶인 결과를 걸러내는 자리일 뿐, 컬럼을 만들거나 가공하는 곳이 아니기 때문입니다. 가격대라는 새 값은 SELECT에서 만들고 GROUP BY에서 그 식으로 묶어야 합니다.
💼 이 유형을 다시 만나면"구간별로 묶어 세기"는 실무 리포트에서 매우 흔한 요구입니다 — 연령대별, 금액대별, 시간대별 집계가 전부 같은 패턴이죠. 구간을 만드는 식을 SELECT와 GROUP BY 양쪽에 똑같이 쓰는 것이 요령입니다.
5회차
추억 점수
JAVA2026.08.24
한 줄 요약사진 속 이름마다 이름 배열을 반복 탐색해 점수를 더해 통과했고, 이름→점수를 HashMap에 담아 getOrDefault로 찾으면 더 빠르고 간결하다.
쉽게 말하면사진 속 얼굴마다 명단을 처음부터 뒤지는 대신, 이름을 넣으면 점수가 바로 나오는 전화번호부(HashMap)를 미리 만들어 두는 셈이다.
그리운 사람 이름과 그리움 점수가 주어질 때, 사진마다 그 안에 등장한 사람들의 점수를 합해 사진별 추억 점수를 구하는 문제.
JAVA제출 코드
class Solution {
public int[] solution(String[] name, int[] yearning, String[][] photo) {
int result[] = new int[photo.length];
for(int i=0;i<photo.length;i++){
int sum=0;
for(int j =0; j<photo[i].length;j++){
int k =0;
while(k<yearning.length){
if(photo[i][j].equals(name[k])){
sum = sum+yearning[k];
System.out.println(sum);
break;
} else k++;
}
result[i] = sum;
}
}
return result;
}
}
💡 정답 풀이
저는 전체 사진을 하나씩 순회 하여 (photo[i]), 해당 사진 속 인물을 한 명씩 확인 (photo[i][j]) 하고 그리워하는 사람 목록(name)을 처음부터 끝까지 선형 탐색 하였습니다. 그 안에서
사진 속 인물과 그리워하는 사람의 이름이 일치하는지 비교하여 점수 누적 합산하였고,
일치하는 사람을 찾으면 탐색 중단 후 해당 사진의 총점을 결과 배열에 저장하는 방식으로 풀었습니다.
JAVA다른 추천 정답 코드
import java.util.HashMap;
import java.util.Map;
class Solution {
public int[] solution(String[] name, int[] yearning, String[][] photo) {
int[] result = new int[photo.length];
// 1. 이름과 그리움 점수를 Map에 저장 (조회 속도 O(1))
Map<String, Integer> scoreMap = new HashMap<>();
for (int i = 0; i < name.length; i++) {
scoreMap.put(name[i], yearning[i]);
}
// 2. 각 사진별 점수 합산
for (int i = 0; i < photo.length; i++) {
int sum = 0;
for (String person : photo[i]) {
sum += scoreMap.getOrDefault(person, 0);
}
result[i] = sum;
}
return result;
}
}
(1) HashMap을 활용한 Key-Value 매핑 - 이름(String)을 통해 점수(Integer)를 빠르게 찾아야 할 때 유용합니다. - 배열 선형 탐색 시 O(N)의 시간이 걸리지만, HashMap을 사용하면 O(1)에 원하는 값을 찾을 수 있습니다.
(2) Map.getOrDefault(Object key, V defaultValue) - Map 안에 조회하려는 Key(사람 이름)가 존재하면 해당 Value(점수)를 반환하고, 존재하지 않으면 기본값(0)을 반환합니다. - 사진에 그리움 점수가 없는 인물이 포함되어 있을 때 if-else 분기 처리 없이 깔끔하게 처리할 수 있습니다.
(3) 향상된 for문 (Enhanced for Loop) - 인덱스(j)가 직접 필요하지 않고 원소 순회만 필요한 경우 'for (String person : photo[i])' 형태로 작성하면 가독성이 좋아지고 실수를 줄일 수 있습니다.
📌 주요 개념 정리
(1) 2차원 배열 순회 및 다중 반복문
- photo는 사진(행)과 사진 속 인물(열)로 이루어진 2차원 배열입니다.
- 바깥쪽 for문(i)으로 사진을 선택하고, 안쪽 for문(j)으로 사진 속 인물을 하나씩 꺼내어 처리하는 다차원 배열 탐색 방식을 사용했습니다.
(2) 선형 탐색 (Linear Search)
- 사진 속 인물이 그리워하는 사람 목록에 있는지 확인하기 위해 while 루프를 사용하여 name 배열의 0번 인덱스부터 끝까지 순차적으로 비교했습니다.
- 별도의 추가 라이브러리(Map 등)를 불러오지 않고 기본 배열을 사용했습니다.
(3) 문자열 값 비교 (equals 메소드)
- 자바에서 String 객체의 문자열 내용이 같은지 비교할 때 '==' 연산자 대신 .equals() 메소드를 사용했습니다.
(4) break 문을 통한 탐색 최적화
- name 배열에는 중복된 이름이 없으므로, 일치하는 이름을 찾았을 때 break로 while 루프를 즉시 탈출하여 불필요한 추가 비교 연산을 방지했습니다
. (5) 누적 변수(sum)의 초기화 위치
- 각 사진마다 점수를 새로 계산해야 하므로, 바깥쪽 for문(i)이 시작될 때 sum 변수를 0으로 선언 및 초기화하여 사진 간 점수 간섭이 없도록 처리했습니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
이름과 점수를 짝지어 빠르게 찾으려면 어떤 자료구조가 맞나?
Map입니다. 이름을 키, 점수를 값으로 두면 map.get(이름)으로 O(1)에 찾을 수 있죠. 배열 두 개로 두고 매번 이름을 순회하며 찾으면 사진 수 × 이름 수만큼 걸려 훨씬 느립니다.
두 배열(name, yearning)을 Map으로 합치는 코드를 떠올려 보세요.
for (int i = 0; i < name.length; i++) map.put(name[i], yearning[i]); — 같은 인덱스끼리 짝이라는 전제를 이용합니다. 이렇게 병렬 배열을 Map으로 바꾸는 전처리는 코딩테스트에서 반복해서 나옵니다.
💼 이 유형을 다시 만나면"이름 → 값" 형태의 조회가 반복되면 즉시 Map을 떠올리세요. 이 전처리 한 번이 O(N²)을 O(N)으로 바꿉니다. 시간 초과가 나는 문제의 상당수가 이 지점입니다.
5회차
상품 별 오프라인 매출 구하기
SQL2026.08.20
한 줄 요약상품코드로 GROUP BY했다면 매출도 SUM(SALES_AMOUNT * PRICE)로 합쳐야 하며, SUM을 빼면 그룹 안 임의의 한 행 값만 계산된다.
쉽게 말하면한 상품의 영수증이 여러 장인데 한 장만 보고 매출이라 하면 틀린다. 묶었으면 SUM으로 전부 더해야 한다.
상품 테이블과 오프라인 판매 테이블을 조인해, 상품 코드별 총 판매액을 구해 조회하는 문제.
기록 주의. 5회차로 묶여 있지만 날짜(2026.08.20)는 4회차 카드들과 같고, 같은 5회차의 추억 점수는 2026.08.24다. 이 시기의 원본 기록은 저장소에 없어 확인하지 못했으므로 그대로 두었다 — 회차·날짜가 원본 기록과 어긋날 수 있다.
SQL제출 코드
SELECT PRODUCT_CODE, (SALES_AMOUNT * PRICE) AS SALES
FROM PRODUCT A, OFFLINE_SALE B
WHERE A.PRODUCT_ID = B.PRODUCT_ID
GROUP BY PRODUCT_CODE
ORDER BY SALES DESC, PRODUCT_CODE ASC;
💡 정답 풀이
SUM을 안 써서 틀렸다.
SELECT 절에서 SUM() 누락
- 문제에서 요구한 것은 상품코드별 '매출액 합계'입니다.
- OFFLINE_SALE 테이블에는 동일한 상품(PRODUCT_ID)이 여러 날짜에 걸쳐 여러 번 판매된 데이터가 존재합니다.
- GROUP BY PRODUCT_CODE로 상품을 묶었지만, SUM()을 쓰지 않고 (SALES_AMOUNT * PRICE)만 적으면, 그룹 내의 전체 판매량 합계가 아니라 임의의 단일 행 판매량 하나만 가져와서 단가를 곱하게 됩니다. 따라서 총 매출이 아닌 엉뚱한 단일 판매건 매출이 계산됩니다.
SQL정답 코드
SELECT PRODUCT_CODE, SUM(SALES_AMOUNT * PRICE) AS SALES
FROM PRODUCT A, OFFLINE_SALE B
WHERE A.PRODUCT_ID = B.PRODUCT_ID
GROUP BY PRODUCT_CODE
ORDER BY SALES DESC, PRODUCT_CODE ASC;
SQL표준 ANSI JOIN 방법 해답
SELECT
A.PRODUCT_CODE,
SUM(B.SALES_AMOUNT * A.PRICE) AS SALES
FROM
PRODUCT A JOIN OFFLINE_SALE B ON A.PRODUCT_ID = B.PRODUCT_ID
GROUP BY A.PRODUCT_CODE
ORDER BY SALES DESC, A.PRODUCT_CODE ASC;
📌 주요 개념 정리
(1) GROUP BY와 집계 함수 (Aggregate Functions)
- GROUP BY를 수행하면 여러 행이 지정된 컬럼을 기준으로 하나의 그룹으로 압축됩니다.
- 그룹화된 이후에는 개별 행의 원본 값을 직접 참조할 수 없으며, 그룹 전체를 대표하는 값(SUM, AVG, MAX, MIN, COUNT 등 집계 함수)만 SELECT 절이나 정렬에 사용할 수 있습니다.
- 특정 상품에 판매 이력이 3건(각 2개, 3개, 1개 판매) 있다면 총 판매량은 6개이므로 반드시 SUM(SALES_AMOUNT) 형태로 합산해야 합니다.
(2) SQL 쿼리 실행 순서 (Execution Order)
1. FROM / JOIN : 데이터 테이블 참조 및 조인 수행
2. WHERE : 개별 행 단위 조건 필터링
3. GROUP BY : 지정된 컬럼 기준으로 행들을 그룹화
4. HAVING : 그룹화된 결과에 대한 조건 필터링
5. SELECT : 출력할 컬럼 및 집계 함수 계산, 별칭(Alias) 부여
6. ORDER BY : 최종 출력 결과를 정렬
7. LIMIT : 출력 행 수 제한
* 중요: ORDER BY는 SELECT보다 나중에 실행되므로, SELECT 절에서 지정한 별칭(AS SALES)을 ORDER BY 절에서 그대로 정렬 기준으로 사용할 수 있습니다.
(3) 조인(JOIN) 작성 방식
- 암시적 조인(Implicit Join): FROM 절에 콤마(,)로 나열하고 WHERE 절에서 조인 조건을 주는 방식 (예: FROM A, B WHERE A.ID = B.ID)
- 명시적 조인(Explicit / ANSI Join): JOIN 키워드와 ON 절을 사용하는 방식 (예: FROM A JOIN B ON A.ID = B.ID)
- 실무 및 코딩테스트에서는 가독성과 유지보수, 실수 방지(WHERE 누락으로 인한 카티션 곱 방지)를 위해 명시적 ANSI 조인을 권장합니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
두 테이블을 조인할 때 어떤 컬럼을 기준으로 삼아야 하나?
양쪽에 공통으로 존재하며 서로를 가리키는 컬럼입니다 — 보통 PK와 FK 관계죠. 이 문제에서는 상품 ID(PRODUCT_ID)가 두 테이블 양쪽에 있어 그것으로 붙입니다. 상품 코드(PRODUCT_CODE)는 PRODUCT 테이블에만 있어서, 조인한 뒤 묶는 기준으로 씁니다.
조인 후 상품별로 합계를 내려면 무엇이 더 필요한가?
GROUP BY로 상품 코드별로 묶고 SUM으로 합산해야 합니다. 조인만 하면 판매 건수만큼 행이 늘어난 상태이므로, 그걸 다시 접어야 상품당 한 줄이 됩니다.
💼 이 유형을 다시 만나면조인 + 그룹화는 실무 SQL에서 가장 흔한 조합입니다. 순서는 항상 ① 필요한 테이블을 조인해 한 표로 만들고 ② 그 표를 그룹으로 묶어 집계입니다. 이 두 단계로 나눠 생각하면 복잡한 문제도 풀립니다.
6회차
진료순서 정하기
JAVA2026.08.26
한 줄 요약오름차순 정렬 뒤 찾은 인덱스 j를 그대로 순위로 써서 틀렸고, 응급도가 높을수록 앞 순위이므로 emergency.length - j로 바꾸거나 자기보다 큰 값의 개수+1로 센다.
쉽게 말하면덜 급한 순으로 줄 세운 자리를 그대로 번호표로 주면 순서가 거꾸로다. 가장 급한 환자가 1번이 되도록 뒤에서부터 세야 한다.
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
class Solution {
public int[] solution(int[] emergency) {
int[] sorted = emergency.clone();
Arrays.sort(sorted);
Map<Integer, Integer> rankMap = new HashMap<>();
for (int i = 0; i < sorted.length; i++) {
rankMap.put(sorted[i], sorted.length - i);
}
int[] answer = new int[emergency.length];
for (int i = 0; i < emergency.length; i++) {
answer[i] = rankMap.get(emergency[i]);
}
return answer;
}
}
순위 카운팅 코드 풀이
모든 원소의 기본 등수를 1등으로 초기화합니다.
2중 반복문으로 배열 내 모든 원소끼리 크기를 비교합니다.
기준 원소보다 더 큰 원소가 나타날 때마다 기준 원소의 등수를 1씩 증가시킵니다.
별도의 정렬이나 라이브러리 없이 순위를 바로 도출할 수 있어 N이 작은 문제(N <= 10)에서 가장 가볍고 빠릅니다.
나의 시도 코드(정렬 + 선형 탐색) 풀이
원본 배열을 복제하여 오름차순으로 정렬된 배열을 만듭니다.
원본 배열의 각 원소가 정렬된 배열의 어느 위치(j)에 있는지 찾습니다.
오름차순 정렬 특성상 뒤쪽 인덱스일수록 큰 값이므로, 등수는 '배열 길이 - j'로 계산하여 저장합니다.
정렬 + HashMap 코드 풀이
원본 배열을 정렬한 뒤, 각 원소의 값과 그에 해당하는 등수를 Map 자료구조에 Key-Value 형태로 미리 저장합니다.
원본 배열 순서대로 Map에서 등수를 O(1) 시간에 즉시 꺼내어 정답 배열을 만듭니다.
N이 10만 개 이상으로 커지더라도 2중 루프 없이 정렬 시간(N log N)만으로 해결되는 가장 범용적인 최적화 구조입니다.
📌 주요 개념 정리
[주요 개념 정리]
오름차순 정렬 후 역순 인덱스 계산 (이 문제에서 틀린 지점)
오름차순 정렬 시 가장 큰 원소는 배열의 마지막 인덱스(length - 1)에 위치합니다.
따라서 가장 큰 원소를 1등으로 매기려면 'length - j' 공식을 적용합니다. (j가 length-1일 때 1이 됨)
순위(Rank) 결정의 기본 원리
특정 값의 등수는 항상 '나보다 큰 값의 개수 + 1'입니다.
시간 복잡도와 선택 기준
N이 10 이하일 때: O(N^2) 알고리즘(2중 루프)이 오버헤드가 없어 가장 단순하고 빠릅니다.
N이 1,000 이상일 때: O(N^2)은 100만 번 이상 연산되므로, O(N log N) 정렬 및 O(1) 탐색이 가능한 Map 방식이 필수적입니다.
(참고) 얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy) — 정렬용 사본을 만들 때
int[] answer = emergency; 처럼 대입 연산자를 쓰면 배열의 주소값만 복사되어 두 변수가 동일한 배열을 가리킵니다. (answer를 수정하면 emergency도 수정됨)
원본 배열을 보존하려면 new int[길이]로 새로 생성하거나 clone(), Arrays.copyOf()를 사용하여 독립된 메모리를 할당해야 합니다. 제출 코드는 for문으로 직접 복사해 이 부분은 맞게 처리했습니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
응급도가 높을수록 먼저 진료받는다면, 순위는 어떻게 구하나?
자기보다 큰 값이 몇 개인지 세면 그것이 곧 등수입니다 — 자기보다 큰 게 2개면 3등이죠. 또는 내림차순 정렬 후 인덱스를 찾아도 됩니다. 전자가 구현이 단순합니다.
정렬을 쓰지 않고 순위를 구하는 방법이 유리한 경우는?
원래 순서를 유지해야 할 때입니다. 이 문제는 입력 순서대로 각자의 등수를 돌려줘야 하는데, 정렬하면 순서가 흐트러지므로 원본 인덱스를 따로 관리해야 하죠. "세기" 방식은 그런 관리가 필요 없습니다.
💼 이 유형을 다시 만나면"순위 구하기"는 코딩테스트 빈출 유형입니다. 데이터가 작으면 이중 반복문으로 세는 방식이 가장 간단하고, 커지면 정렬 + 인덱스 맵으로 최적화합니다. SQL이라면 RANK() 윈도우 함수가 같은 일을 합니다.
6회차
자동차 평균 대여 기간 구하기
SQL2026.08.26
한 줄 요약대여 기간은 DATEDIFF(END_DATE, START_DATE) + 1로 당일 대여를 1일로 치고, 평균 7일 이상 조건은 HAVING에, 소수 첫째 자리는 ROUND로 맞춘다.
쉽게 말하면오늘 빌려 오늘 반납해도 하루를 쓴 것이다. 날짜끼리 뺀 값에 1을 더해야 진짜 대여 일수가 된다.
자동차 대여 기록 테이블에서 차량별 평균 대여 기간을 구하고, 평균이 7일 이상인 차량만 조회하는 문제.
SQL제출 코드
SELECT
CAR_ID,
AVG(END_DATE-START_DATE) AS AVERAGE_DURATION
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY
GROUP BY CAR_ID
ORDER BY AVERAGE_DURATION DESC, CAR_ID DESC;
💡 정답 풀이
제출 코드는 날짜를 그냥 빼고(END_DATE-START_DATE) +1을 하지 않았으며, 평균 7일 이상 조건(HAVING)과 소수 첫째 자리 반올림(ROUND)이 빠져 있었다.
SQL정답 코드 (MySQL 기준)
SELECT CAR_ID, ROUND(AVG(DATEDIFF(END_DATE, START_DATE) + 1), 1) AS AVERAGE_DURATION
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY
GROUP BY CAR_ID
HAVING AVG(DATEDIFF(END_DATE, START_DATE) + 1) >= 7
ORDER BY AVERAGE_DURATION DESC, CAR_ID DESC;
SQL정답 코드 (Oracle 기준)
SELECT CAR_ID, ROUND(AVG(END_DATE - START_DATE + 1), 1) AS AVERAGE_DURATION
FROM CAR_RENTAL_COMPANY_RENTAL_HISTORY
GROUP BY CAR_ID
HAVING AVG(END_DATE - START_DATE + 1) >= 7
ORDER BY AVERAGE_DURATION DESC, CAR_ID DESC;
FROM 절: CAR_RENTAL_COMPANY_RENTAL_HISTORY 테이블에서 대여 기록 데이터를 가져옵니다.
GROUP BY 절: CAR_ID를 기준으로 묶어 자동차별 그룹을 생성합니다.
날짜 차이 계산 (+1 처리): 대여 기간을 계산할 때 MySQL은 DATEDIFF(END_DATE, START_DATE) + 1, Oracle은 END_DATE - START_DATE + 1을 사용합니다. 시작일과 종료일이 같은 날(당일 대여 및 반납)도 대여 기간 1일로 포함되어야 하므로 반드시 1을 더해줍니다.
AVG() 및 ROUND(): AVG 함수는 그룹화된 각 자동차별 대여 기간을 자동으로 합산하여 건수로 나눈 평균값을 구합니다. 소수점 두 번째 자리에서 반올림하여 첫째 자리까지 표시해야 하므로 ROUND(평균값, 1)로 감싸고 별칭을 AVERAGE_DURATION으로 지정합니다.
HAVING 절: 평균 대여 기간이 7일 이상인 그룹만 필터링해야 하므로 GROUP BY 뒤에 HAVING AVG(...) >= 7 조건을 적용합니다.
ORDER BY 절: 평균 대여 기간(AVERAGE_DURATION) 내림차순(DESC)으로 정렬하고, 값이 같다면 CAR_ID 내림차순(DESC)으로 정렬합니다.
📌 주요 개념 정리
AVG() 함수의 내부 동작과 SUM() 생략 이유
AVG(컬럼 또는 연산식)는 내부적으로 해당 그룹 내 데이터의 합계(SUM)를 구한 뒤 개수(COUNT)로 나누는 연산을 자동으로 수행합니다.
따라서 GROUP BY로 묶인 그룹의 평균을 구할 때는 SUM(AVG(...))처럼 중첩할 필요가 없으며, SQL 표준 문법에서도 집계 함수의 직접 중첩은 허용되지 않습니다.
대여 기간 계산 시 당일 포함(+1) 규칙
날짜 차이 계산 시 단순 종료일 - 시작일 또는 DATEDIFF 연산은 간격(Day difference)만을 계산합니다.
예를 들어 9월 5일 대여 후 9월 5일 반납 시 단순 차이는 0이지만 실제 대여 일수는 1일입니다.
이처럼 시작일이 기간에 포함되는 연속 기간 계산 시에는 반드시 +1 처리를 해주어야 합니다.
WHERE 절과 HAVING 절의 차이점
WHERE 절: 테이블의 개별 행 단위로 조건을 검사하며, GROUP BY로 그룹화하기 전에 먼저 실행됩니다. 집계 함수(AVG, SUM 등)를 사용할 수 없습니다.
HAVING 절: GROUP BY로 그룹화되고 집계 함수가 계산된 이후에 그룹 단위로 조건을 검사합니다. 집계 함수를 조건으로 사용할 때 반드시 사용합니다.
SQL 실행 순서 1단계: FROM (테이블 조회) 2단계: WHERE (개별 행 필터링) 3단계: GROUP BY (그룹화) 4단계: HAVING (그룹 조건 필터링) 5단계: SELECT (컬럼 조회, 집계 연산, 별칭 지정) 6단계: ORDER BY (정렬 수행)
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
평균 대여 기간을 구할 때 날짜 차이를 어떻게 계산하나?
MySQL에서는 DATEDIFF(END_DATE, START_DATE)로 일수 차이를 구합니다. END_DATE - START_DATE처럼 날짜를 그냥 빼면 MySQL·MariaDB는 날짜를 YYYYMMDD 숫자로 바꿔 뺄셈을 합니다. 그래서 2022-10-01 - 2022-09-30은 1이 아니라 20221001 - 20220930 = 71이 되죠 — 달이나 해가 바뀌는 순간 값이 틀어지므로 DATEDIFF 같은 전용 함수를 써야 합니다.
'평균이 7일 이상'이라는 조건은 WHERE인가 HAVING인가?
HAVING입니다. 평균은 차량별로 묶어 봐야 알 수 있는 값(집계)이므로, 묶기 전에 동작하는 WHERE에는 쓸 수 없습니다.
💼 이 유형을 다시 만나면날짜 계산은 반드시 전용 함수를 쓰세요 — DATEDIFF, TIMESTAMPDIFF, DATE_ADD. 직접 빼면 DB마다 결과가 달라 이식성도 떨어집니다. 실무에서도 날짜는 가장 실수가 잦은 영역입니다.
7회차
완주하지 못한 선수
JAVA2026.08.28
한 줄 요약이름별 인원수를 HashMap에 세고 완주자만큼 빼서 0이 아닌 이름을 찾거나, 두 배열을 정렬해 처음 어긋나는 자리를 찾는다.
쉽게 말하면출석부에 이름마다 사람 수만큼 표시하고 도착한 사람마다 하나씩 지우면, 남은 표시가 못 온 사람이다. 동명이인도 표시 개수로 구분된다.
참가자 명단과 완주자 명단을 비교해 완주하지 못한 단 한 명을 찾는 문제. 동명이인이 있을 수 있는 것이 핵심.
⚠ 이 문제는 원본 노트에 제출 코드가 기록되어 있지 않습니다 — 정답 풀이와 개념만 정리합니다.
💡 정답 풀이
제출 코드가 기록되지 않아 틀린 이유는 남아 있지 않다. 핵심은 동명이인이 있을 수 있다는 점 — 이름만으로 "있다/없다"를 판단하면 틀리므로 이름별 인원수를 세야 한다. 흔히 틀리는 지점은 ① contains나 Set으로 이름이 "있는지"만 확인해 동명이인을 놓치는 것, ② 참가자가 최대 10만 명인데 이중 반복문으로 하나씩 비교해 시간 초과가 나는 것이다.
JAVA버전 1: HashMap 활용 — 권장 풀이
import java.util.HashMap;
class Solution {
public String solution(String[] participant, String[] completion) {
HashMap<String, Integer> map = new HashMap<>();
for (String player : participant) {
map.put(player, map.getOrDefault(player, 0) + 1);
}
for (String player : completion) {
map.put(player, map.get(player) - 1);
}
for (String key : map.keySet()) {
if (map.get(key) != 0) {
return key;
}
}
return "";
}
}
JAVA버전 2: Arrays.sort 활용 — 정렬 풀이
import java.util.Arrays;
class Solution {
public String solution(String[] participant, String[] completion) {
Arrays.sort(participant);
Arrays.sort(completion);
for (int i = 0; i < completion.length; i++) {
if (!participant[i].equals(completion[i])) {
return participant[i];
}
}
return participant[participant.length - 1];
}
}
[풀이 1: HashMap 방식]
1. 해시맵 생성: 선수 이름을 Key로, 해당 이름을 가진 선수 인원수를 Value(Integer)로 관리할 HashMap을 생성합니다.
2. 참가자 카운팅: participant 배열을 순회하면서 map.getOrDefault(player, 0) + 1을 통해 처음 보는 선수는 1, 동명이인은 기존 값에 +1을 더해 저장합니다.
3. 완주자 차감: completion 배열을 순회하면서 완주한 선수의 인원수를 map.get(player) - 1로 1씩 줄여줍니다.
4. 미완주자 반환: map.keySet()으로 전체 이름을 확인하며 값이 0이 아닌(1로 남아있는) 선수를 찾아 반환합니다.
[풀이 2: 정렬(Sorting) 방식]
1. 배열 정렬: participant와 completion 두 배열을 Arrays.sort()를 이용해 알파벳 오름차순으로 정렬합니다.
2. 일대일 비교: 동일한 인덱스 i에 대해 participant[i]와 completion[i]를 비교합니다. 완주하지 못한 선수가 있다면 그 순간 두 배열의 이름이 달라지게 되므로 그때의 participant[i]를 반환합니다.
3. 마지막 원소 처리: completion 길이만큼 순회했는데도 다른 이름이 나오지 않았다면, 참가자 명단의 가장 마지막 인덱스에 있는 선수가 미완주자입니다.
📌 주요 개념 정리
1. 시간 복잡도와 효율성 문제 (O(N^2) vs O(N))
- 참가자 수가 최대 10만(100,000)명이므로, 2중 for문으로 하나씩 일치 여부를 비교하면 최악의 경우 100,000 * 100,000 = 100억 번의 연산이 발생하여 무조건 시간 초과가 발생합니다.
- HashMap 방식은 탐색 및 삽입 시간이 평균 O(1)이므로 전체 시간 복잡도가 O(N)으로 매우 빠릅니다.
- 정렬 방식은 정렬 시간 복잡도 O(N log N)을 따릅니다.
2. HashMap의 핵심 메서드
- map.put(key, value) : 맵에 Key-Value 쌍을 추가하거나 기존 값을 덮어씁니다.
- map.get(key) : Key에 매핑된 Value를 조회합니다. 존재하지 않는 Key 조회 시 null을 반환합니다.
- map.getOrDefault(key, defaultValue) : Key가 존재하면 해당 Value를 반환하고, 없으면 지정한 defaultValue를 반환하여 NullPointerException을 방지합니다.
- map.keySet() : 맵에 저장된 모든 Key들을 Set 형태로 모아서 순회할 수 있게 합니다.
3. 동명이인(중복 데이터) 처리 원리
- 단순 boolean 체크 방식은 동명이인 중 1명이 완주해도 전체가 true로 바뀌는 논리적 오류가 생깁니다.
- HashMap에 인원수를 누적(+1)하고 완주 시 차감(-1)하는 '카운팅 기법'을 사용하면 동명이인도 완벽하게 추적할 수 있습니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
동명이인이 있을 수 있다는 조건이 왜 결정적인가?
단순히 "명단에 있나 없나"로 판단할 수 없게 만들기 때문입니다. 같은 이름이 둘인데 하나만 완주했다면 이름은 여전히 완주자 명단에 있습니다. 그래서 개수를 세어 비교해야 하고, 그것이 Map을 쓰는 이유입니다.
정렬 후 비교하는 방식은 왜 정답이 되나?
양쪽을 같은 기준으로 줄 세우면 딱 한 명만 어긋나기 때문입니다. 앞에서부터 짝을 맞춰 가다 처음으로 다른 이름이 나오는 자리가 미완주자죠. 끝까지 같으면 참가자의 마지막 사람이 답입니다.
💼 이 유형을 다시 만나면이 문제는 해시(Map)와 정렬 두 가지 접근을 비교하는 교과서적 사례입니다. Map은 O(N), 정렬은 O(N log N)이죠. "개수를 세어 비교"라는 발상은 애너그램·중복 찾기 등 수많은 문제에 그대로 쓰입니다.
7회차
연도 별 평균 미세먼지 농도 조회하기
SQL2026.08.28
한 줄 요약YEAR(YM)으로 연도를 뽑아 GROUP BY하고 ROUND(AVG(...), 2)로 평균을 내며, 점이 들어간 별칭 PM2.5는 따옴표로 감싸야 한다(MySQL 식별자 표기는 백틱 `PM2.5`, AS 뒤에서는 큰따옴표도 허용).
쉽게 말하면매달 적은 미세먼지 일지를 해마다 한 권으로 묶어 평균을 낸다. 이름표에 점이 들어가면 따옴표로 감싸 이것이 이름임을 알려 줘야 한다.
월별 미세먼지 테이블에서 특정 지역의 연도별 평균 미세먼지 · 초미세먼지 농도를 구해 조회하는 문제.
⚠ 원본 노트의 "제출 코드" 칸에 직전 문제(자동차 평균 대여 기간 구하기)의 코드가 그대로 남아 있어(붙여넣기 실수로 보임) 이 문제 카드에서는 생략하고, 정답 풀이와 개념만 정리합니다.
💡 정답 풀이
제출 코드가 남아 있지 않아 실제로 틀린 이유는 알 수 없다. 이 문제에서 흔히 틀리는 지점은 ① 별칭 PM2.5를 따옴표 없이 쓰는 것, ② YEAR(YM) 대신 YM 그대로 묶어 월별로 나뉘는 것, ③ ROUND(..., 2) 자릿수나 수원 조건을 빠뜨리는 것이다.
특수문자 별칭 처리: 마침표가 들어간 별칭 PM2.5는 그대로 쓰면 오류가 나므로 따옴표로 감싸야 합니다. MySQL에서 식별자(컬럼명·별칭)를 감싸는 기호는 백틱(`PM2.5`)이고, 큰따옴표 "PM2.5"는 MySQL 기본 설정에서 문자열로 읽힙니다 — 다만 AS 뒤에 쓴 문자열은 별칭으로 받아 주기 때문에 이 문제에서는 큰따옴표도 동작합니다.
SQL정답 코드
SELECT YEAR(YM) AS YEAR,
ROUND(AVG(PM_VAL1), 2) AS PM10,
ROUND(AVG(PM_VAL2), 2) AS "PM2.5" -- MySQL 식별자 표기로는 `PM2.5`
FROM AIR_POLLUTION
WHERE LOCATION2 = '수원'
GROUP BY YEAR(YM)
ORDER BY YEAR ASC;
연도별 그룹화: YM은 날짜 데이터이므로 YEAR(YM)으로 연도를 추출하여 GROUP BY에 전달해야 합니다.
WHERE 절: LOCATION2 = '수원' 조건으로 수원 지역의 측정 데이터만 먼저 필터링합니다.
GROUP BY 절: YEAR(YM)으로 연도만 추출하여 같은 연도끼리 그룹을 만듭니다.
SELECT 절:
YEAR(YM) AS YEAR : 연도를 출력합니다.
ROUND(AVG(PM_VAL1), 2) AS PM10 : 미세먼지 평균을 계산하고 소수 셋째 자리에서 반올림하여 소수 둘째 자리까지 표시합니다.
ROUND(AVG(PM_VAL2), 2) AS "PM2.5" : 초미세먼지 평균을 계산하고 소수 둘째 자리까지 반올림합니다.
ORDER BY 절: 연도를 기준으로 오름차순(ASC) 정렬합니다.
📌 주요 개념 정리
작은따옴표('')와 큰따옴표("")의 구분 (SQL 표준)
작은따옴표(' '): 테이블 내부의 '데이터 값(문자열, 날짜)'을 표현할 때 사용합니다. (예: WHERE LOCATION2 = '수원')
큰따옴표(" "): 컬럼명, 테이블명, 별칭(Alias) 같은 '식별자'를 감쌀 때 사용합니다. 특히 별칭에 특수문자(.), 공백이 들어갈 때 필수입니다. (예: AS "PM2.5")
Oracle 등 표준 SQL 환경에서는 데이터 값에 큰따옴표를 쓰면 컬럼명으로 인식하여 에러가 발생합니다.
MySQL·MariaDB는 예외: 식별자는 백틱(`)으로 감싸고, 큰따옴표는 기본 설정(ANSI_QUOTES 꺼짐)에서 작은따옴표처럼 '문자열'로 취급합니다.
MySQL 날짜 추출 함수
YEAR(날짜): 연도 반환 (예: 2018)
MONTH(날짜): 월 반환 (예: 1~12)
DAY(날짜): 일 반환 (예: 1~31)
ROUND(숫자, 자릿수) 반올림 규칙
ROUND(숫자, 0): 소수 첫째 자리에서 반올림 (정수 형태)
ROUND(숫자, 1): 소수 둘째 자리에서 반올림 (소수 첫째 자리까지 표시)
ROUND(숫자, 2): 소수 셋째 자리에서 반올림 (소수 둘째 자리까지 표시)
집계 함수와 함께 쓸 때는 반드시 ROUND(AVG(컬럼), 자릿수) 형태로 집계 함수 괄호를 먼저 닫아주어야 합니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
별칭에 PM2.5처럼 점이 들어가면 왜 따옴표가 필요한가?
점(.)이 SQL에서 '테이블명.컬럼명'을 구분하는 특수 기호이기 때문입니다. 그냥 쓰면 PM2라는 테이블의 5 컬럼으로 해석되죠. 따옴표로 감싸 "이건 통째로 하나의 이름"이라고 알려야 합니다. MySQL에서 식별자용 기호는 백틱(`PM2.5`)이고, 큰따옴표는 기본적으로 문자열이지만 AS 뒤 별칭 자리에서는 받아 줍니다.
연도별로 묶으려면 GROUP BY에 무엇을 써야 하나?
YEAR(YM)입니다. YM은 날짜라 그대로 묶으면 월 단위로 다 쪼개집니다. 가공한 식으로 묶을 수 있다는 성질을 이용해 연도만 추출해 묶는 것이죠.
💼 이 유형을 다시 만나면가공한 식으로 GROUP BY하는 패턴은 실무 리포트의 핵심입니다 — 연도별, 월별, 요일별, 시간대별 집계가 전부 이것이죠. YEAR()·MONTH()·DATE_FORMAT()을 익혀 두면 두루 씁니다.
8회차
마지막 두 원소
JAVA2026.08.31
한 줄 요약길이를 1 늘린 새 배열에 기존 값을 옮기고 마지막 두 원소를 비교해 끝자리를 채우며, Arrays.copyOf로 생성과 복사를 한 줄로 줄일 수 있다.
쉽게 말하면자바 배열은 늘어나지 않는 상자라, 한 칸 더 큰 새 상자로 짐을 옮긴 뒤 마지막 칸에 새 값을 넣는다.
통과한 코드지만, Arrays.copyOf를 쓰면 배열 생성과 복사를 한 줄로 줄일 수 있습니다.
[더 짧은 다른 답]
import java.util.Arrays;
class Solution {
public int[] solution(int[] num_list) {
int len = num_list.length;
// 길이를 1 늘려서 기존 배열을 통째로 복사
int[] answer = Arrays.copyOf(num_list, len + 1);
int last = num_list[len - 1];
int prev = num_list[len - 2];
// 삼항 연산자로 마지막 자리에 넣을 값을 결정
answer[len] = (last > prev) ? (last - prev) : (last * 2);
return answer;
}
}
풀이 흐름
1. 원소가 하나 늘어나므로 길이가 length + 1인 새 배열을 만든다.
2. 기존 값을 0번부터 그대로 옮겨 담는다.
3. 마지막 원소는 length-1, 그전 원소는 length-2 인덱스다.
마지막이 더 크면 두 값의 차를, 아니면 마지막의 2배를 새 배열 끝자리에 넣는다.
4. 완성된 배열을 반환한다.
📌 주요 개념 정리
(1) 자바의 1차원 배열 복사 방법 네 가지
· Arrays.copyOf(원본, 새길이) — 새 크기의 배열을 만들면서 값까지 복사한다. 크기를 늘리거나 줄이며 복사할 때 가장 많이 쓴다.
· Arrays.copyOfRange(원본, 시작, 끝) — 특정 구간만 잘라내(끝 인덱스 직전까지) 새 배열로 만든다.
· System.arraycopy(원본, 원본시작, 대상, 대상시작, 개수) — 이미 만들어져 있는 배열의 특정 위치에 덮어쓴다.
· 원본.clone() — 크기와 값이 완전히 같은 배열을 복제한다.
(2) 얕은 복사 vs 깊은 복사
· 얕은 복사는 참조 주솟값만 복사해서, 복사본을 고치면 원본도 함께 바뀐다.
· 깊은 복사는 메모리를 새로 잡아 값 자체를 복사한다.
· int 같은 원시 타입의 1차원 배열은 Arrays.copyOf나 clone()만으로도 완전한 깊은 복사가 된다.
· 하지만 객체 배열이나 2차원 배열은 안쪽 요소가 다시 참조라서, 행마다 clone()을 부르거나 복사 생성자·직렬화를 써야 진짜 깊은 복사가 된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
Arrays.copyOf가 이 문제에서 코드를 얼마나 줄여 주나?
배열 생성과 복사를 한 줄로 합쳐 줍니다. 직접 하면 new int[len+1] 후 반복문으로 옮겨야 하는데, Arrays.copyOf(num_list, len+1)이면 끝이죠. 남는 칸은 자동으로 0으로 채워집니다.
삼항 연산자로 바꾸면 무엇이 좋아지나?
"둘 중 하나를 고른다"는 의도가 한 줄에 드러납니다. if-else 네 줄이 (last > prev) ? (last - prev) : (last * 2) 한 줄이 되죠. 다만 조건이 복잡해지면 오히려 읽기 어려워지므로 단순한 선택에만 씁니다.
💼 이 유형을 다시 만나면Arrays 유틸리티(copyOf·copyOfRange·sort·fill·toString)는 코딩테스트에서 시간을 크게 아껴 줍니다. 직접 구현할 수 있더라도 있는 도구를 쓰는 것이 실수도 줄고 빠릅니다.
8회차
노선별 평균 역 사이 거리 조회하기
SQL2026.08.31
한 줄 요약CONCAT(..., 'km')으로 단위를 붙이고, 문자열이 된 별칭 대신 SUM(D_BETWEEN_DIST) 숫자식으로 정렬해야 한다.
쉽게 말하면숫자에 km를 붙이는 순간 글자가 되어 사전 순으로 비교된다. 그러면 2km가 100km보다 큰 값이 되므로, 정렬은 꼬리표를 붙이기 전 숫자로 한다.
지하철 구간 테이블에서 노선별 총 누계 거리와 평균 역 사이 거리를 구하고, 두 값 모두 'km' 단위를 붙여 총 거리가 먼 노선부터 조회하는 문제.
SQL제출 코드
SELECT ROUTE, ROUND(SUM(D_BETWEEN_DIST),1) AS TOTAL_DISTANCE,ROUND(AVG(D_BETWEEN_DIST),2) AS AVERAGE_DISTANCE
FROM SUBWAY_DISTANCE
GROUP BY ROUTE
ORDER BY TOTAL_DISTANCE DESC;
💡 정답 풀이
SELECT
ROUTE,
CONCAT(ROUND(SUM(D_BETWEEN_DIST), 1), 'km') AS TOTAL_DISTANCE,
CONCAT(ROUND(AVG(D_BETWEEN_DIST), 2), 'km') AS AVERAGE_DISTANCE
FROM SUBWAY_DISTANCE
GROUP BY ROUTE
ORDER BY SUM(D_BETWEEN_DIST) DESC;
틀린 이유 두 가지
1. 'km' 단위를 안 붙였다. 문제가 요구한 출력 형식이므로 CONCAT(..., 'km')으로 이어 붙여야 한다.
2. 정렬 기준을 별칭으로 썼다.CONCAT을 거치면 결과가 문자열이 되므로, 그 별칭(TOTAL_DISTANCE)으로 정렬하면 숫자 순서가 아니라 사전순으로 정렬된다. ORDER BY에는 가공 전 숫자 식인 SUM(D_BETWEEN_DIST)를 써야 한다.
풀이 흐름
1. GROUP BY ROUTE로 노선별로 묶는다.
2. ROUND(SUM(...), 1)로 총 거리를 소수 첫째 자리까지, ROUND(AVG(...), 2)로 평균을 소수 둘째 자리까지 구한다.
3. 각각 CONCAT(..., 'km')으로 단위를 붙이고 요구된 별칭을 단다.
4. ORDER BY SUM(D_BETWEEN_DIST) DESC로 총 거리가 먼 노선부터 정렬한다.
📌 주요 개념 정리
(1) 문자열 결합 함수
· MySQL: CONCAT(문자열1, 문자열2, ...) — 예) CONCAT(12.5, 'km') → '12.5km'
· Oracle: CONCAT(a, b) 또는 연결 연산자 || — 예) ROUND(SUM(D),1) || 'km'(2) 문자열 정렬 vs 숫자 정렬 — ORDER BY의 치명적 함정
· 숫자형 정렬: 2 < 10 < 100 (크기 순)
· 문자열 정렬: '100km' < '10km' < '2km' (첫 글자부터 문자 코드 비교)
· CONCAT()을 거친 결과는 타입이 문자열로 강제 변환되므로, 그 별칭으로 ORDER BY를 하면 순서가 왜곡된다.
· 해결법: ORDER BY 절에는 CONCAT 이전의 순수 숫자 집계식을 쓴다.
(3) ROUND(숫자, 표시할 자릿수) 규칙 재확인
· "소수 둘째 자리에서 반올림" → ROUND(값, 1) → 소수 첫째 자리까지 표시
· "소수 셋째 자리에서 반올림" → ROUND(값, 2) → 소수 둘째 자리까지 표시
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
CONCAT으로 단위를 붙인 컬럼으로 정렬하면 왜 순서가 망가지나?
결과가 문자열이 되어 사전순으로 정렬되기 때문입니다. '100km' < '10km' < '2km'처럼 첫 글자부터 비교하죠. 숫자 순서를 원하면 ORDER BY에 가공 전 숫자 식(SUM(...))을 써야 합니다.
'소수 둘째 자리에서 반올림'은 ROUND의 두 번째 인자로 무엇을 주나?
1입니다. 두 번째 인자는 남길 소수 자릿수이므로, 둘째 자리에서 반올림하면 첫째 자리까지 남습니다. 문제 문구와 인자가 한 칸 어긋나 보이는 것이 헷갈림의 원인입니다.
💼 이 유형을 다시 만나면단위를 붙이는 순간 타입이 문자열로 바뀐다는 것은 실무에서도 중요합니다. 그래서 화면 표시용 포맷은 애플리케이션에서 하고 DB는 숫자 그대로 돌려주는 것이 일반적인 설계입니다.
9회차
컨트롤 제트
JAVA2026.09.01
한 줄 요약split 결과는 String[]이고 숫자 변환은 Integer.parseInt, 문자열 비교는 equals를 써야 하며, Z를 만나면 직전 숫자를 뺀다.
쉽게 말하면되돌리기(Ctrl+Z)처럼 Z가 나오면 바로 앞에 더한 숫자를 다시 빼면 된다. 글자로 된 숫자는 parseInt로 진짜 숫자로 바꿔야 계산할 수 있다.
숫자와 "Z"가 공백으로 구분된 문자열이 주어질 때 숫자를 차례로 더하되, "Z"를 만나면 바로 직전에 더한 숫자를 다시 빼서 최종 합을 구하는 문제.
JAVA제출 코드
class Solution {
public int solution(String s) {
char[] arr2 = s.split(" ");
int sum = 0;
for(int i=0;i<arr2.length;i++){
if(((int)arr2[i]) && arr2[i]!="Z"){
sum = sum + ((int)arr2[i])
}
else if(arr2[i]=="Z"){
sum = sum - (int)arr[i-1];
}
}
return sum;
}
}
💡 정답 풀이
컴파일도 되지 않는 코드였다. 고칠 곳이 여섯 군데다.
1. 반환 타입 불일치 — s.split(" ")은 String[]를 돌려준다. 문자 하나를 담는 char[]에는 대입할 수 없다.
2. 형변환 불가 — (int) 캐스팅은 double→int처럼 기본 숫자 타입끼리만 된다. "10" 같은 문자열은 Integer.parseInt()로 바꿔야 한다.
3. 조건식에 숫자 — 자바의 if 안에는 boolean만 올 수 있다. C나 파이썬처럼 숫자를 조건으로 쓸 수 없다.
4. 문자열 비교에 == / != — String은 참조 타입이라 ==는 주솟값을 비교한다. 내용 비교는 .equals(). (String끼리라면 컴파일은 되지만 결과가 틀리는 논리 오류다.)
5. 변수명 오타 — arr2로 선언해 놓고 arr[i-1]로 접근해 cannot find symbol.
6. 세미콜론 누락 — sum = sum + ((int)arr2[i]) 줄 끝에 ;가 없다.
[정답 코드]
class Solution {
public int solution(String s) {
// 1. 공백 기준으로 잘라 String 배열에 담는다
String[] arr = s.split(" ");
int sum = 0;
// 2. 처음부터 끝까지 순회
for (int i = 0; i < arr.length; i++) {
// "Z"가 아니면 숫자이므로 더한다
if (!arr[i].equals("Z")) {
sum += Integer.parseInt(arr[i]);
}
// "Z"라면 바로 직전 숫자를 뺀다
else {
sum -= Integer.parseInt(arr[i - 1]);
}
}
return sum;
}
}
작동 원리
· "10 Z 20 Z 1" → ["10","Z","20","Z","1"]로 분리된다.
· 숫자면 Integer.parseInt()로 바꿔 누적하고, "Z"면 i-1 위치의 숫자를 빼 준다.
· 문제 조건에 "Z"가 연속으로 나오지 않는다고 명시되어 있어 i-1은 항상 유효한 숫자다.
📌 주요 개념 정리
(1) 문자열 자르기 — split()
구분자를 기준으로 잘라 String[]로 돌려준다. 예) String[] arr = s.split(" ");(2) 문자열 비교 — equals() vs ==
· String은 기본형이 아니라 참조 자료형(객체)이다.
· ==는 메모리 주솟값을 비교하므로, 내용이 같아도 주소가 다르면 false다.
· 내용을 비교하려면 반드시 .equals()를 쓴다. 예) arr[i].equals("Z")(3) 문자열 → 정수 변환 — Integer.parseInt()
· (int) 캐스팅은 숫자 타입끼리만 가능하다. "123" 같은 String에는 쓸 수 없다.
· 자바는 대소문자를 엄격히 구분한다 — ParseInt나 Parseint는 안 된다.
(4) if 조건은 boolean만
C/C++·파이썬과 달리 자바의 if에는 참·거짓 값만 들어갈 수 있다. 숫자를 단독으로 조건에 쓸 수 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
자바에서 if 조건에 숫자를 쓸 수 없는 이유는?
자바는 boolean과 숫자를 엄격히 구분하기 때문입니다. C나 파이썬은 0을 거짓, 나머지를 참으로 보지만 자바는 그런 암묵적 변환을 하지 않죠. 실수를 줄이려는 설계입니다 — if (a = 1) 같은 오타를 컴파일 단계에서 잡을 수 있습니다.
(int) 캐스팅과 Integer.parseInt()는 각각 언제 쓰나?
(int)는 숫자 타입끼리(double→int, long→int), Integer.parseInt()는 문자열→숫자입니다. "123"은 문자열이라 캐스팅으로 바꿀 수 없죠 — 표현 방식 자체가 다르기 때문입니다.
💼 이 유형을 다시 만나면이 문제의 제출 코드는 여섯 군데 오류가 한꺼번에 있었습니다. 이런 경우 컴파일 에러를 하나씩 순서대로 고치는 것이 정석입니다 — 첫 에러를 고치면 뒤의 에러가 사라지는 경우도 많으니 맨 위 에러부터 보세요.
9회차
조건에 맞는 사원 정보 조회하기
SQL2026.09.01
한 줄 요약두 테이블을 EMP_NO로 JOIN하고 2022년만 걸러 사원별 SUM(SCORE)를 낸 뒤, MAX(SUM()) 중첩 대신 ORDER BY SCORE DESC LIMIT 1로 1등을 뽑는다.
쉽게 말하면사원마다 상·하반기 성적표를 더해 한 해 총점을 내고, 총점 순으로 줄 세워 맨 앞 한 명만 데려온다.
사원 테이블과 평가 테이블을 조인해, 2022년 한 해 평가 점수 합이 가장 높은 사원 한 명의 점수·사번·이름·직책·이메일을 조회하는 문제.
SQL제출 코드
-- 제출하지 못했다 (시간 내 작성 실패)
💡 정답 풀이
SELECT
SUM(G.SCORE) AS SCORE,
E.EMP_NO,
E.EMP_NAME,
E.POSITION,
E.EMAIL
FROM HR_EMPLOYEES E
JOIN HR_GRADE G ON E.EMP_NO = G.EMP_NO
WHERE G.YEAR = 2022
GROUP BY E.EMP_NO
ORDER BY SCORE DESC
LIMIT 1;
풀이 흐름
1. 테이블 조인 — 사원 정보(이름·직책·이메일)는 HR_EMPLOYEES에, 점수는 HR_GRADE에 있다. 공통 식별자인 EMP_NO로 INNER JOIN한다. HR_DEPARTMENT는 출력 컬럼에 없으므로 조인할 필요가 없다.
2. 연도 필터링 — 2022년 한 해 점수만 필요하므로 WHERE G.YEAR = 2022로 먼저 걸러낸다.
3. 그룹화와 합산 — 사원마다 상반기·하반기 두 개의 점수가 있으므로 GROUP BY E.EMP_NO로 묶고 SUM(G.SCORE)으로 한 해 총점을 낸다. 별칭은 문제 요구대로 SCORE.
4. 최상위 1명 추출 — ORDER BY SCORE DESC로 내림차순 정렬한 뒤 LIMIT 1로 1등만 남긴다.
📌 주요 개념 정리
(1) 집계 함수는 중첩할 수 없다MAX(SUM(SCORE))처럼 집계 함수 안에 집계 함수를 직접 넣을 수 없다. "합계 중 최댓값"을 구하려면 정렬 후 잘라내기(ORDER BY ... LIMIT 1)를 쓰거나, 서브쿼리로 합계를 먼저 표로 만든 뒤 바깥에서 MAX를 씌워야 한다.
(2) GROUP BY와 SELECT 절의 관계
집계 함수를 쓸 때 집계 대상이 아닌 일반 컬럼(EMP_NAME 등)이 SELECT에 있다면, 그 컬럼들을 모두 GROUP BY에 적는 것이 가장 안전한 작성법이다. 다만 위 정답처럼 기본 키(E.EMP_NO)로 묶었다면 이름·직책·이메일은 그 키 하나로 정해지는 값(함수 종속)이라, MySQL 5.7+의 ONLY_FULL_GROUP_BY에서도 허용된다(ct-50과 같은 원리). 기본 키가 아닌 컬럼으로 묶을 때는 SELECT의 일반 컬럼을 GROUP BY에도 적어야 한다.
(3) 쉼표 조인 vs 명시적 JOINFROM A, B WHERE A.id = B.id 방식은 연결 조건을 빠뜨리면 모든 행이 곱해지는 카티시안 곱이 생기기 쉽다. 가독성과 안전성을 위해 JOIN ... ON을 쓰는 것이 권장된다.
(4) 최댓값 조회 방식 비교
· ORDER BY ... LIMIT 1 — 가장 간결하고 빠르다. 단독 1등을 찾을 때 표준적으로 쓴다.
· HAVING SUM(...) = (SELECT MAX(...)) — 동점자가 여러 명일 때 모두 출력해야 한다면 이 방식이 필요하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
MAX(SUM(SCORE))처럼 집계를 중첩할 수 없을 때 대안 두 가지는?
① ORDER BY SUM(...) DESC LIMIT 1 — 정렬 후 자르기(간결하고 빠름) ② 서브쿼리로 합계 표를 먼저 만든 뒤 바깥에서 MAX — 동점자를 모두 보여줘야 할 때 필요합니다.
동점자가 여러 명일 때 LIMIT 1이 문제가 되는 이유는?
그중 한 명만 나오기 때문입니다. 1등이 셋인데 하나만 보여 주면 틀린 답이 되죠. 이때는 HAVING SUM(...) = (SELECT MAX(...)) 방식으로 최댓값과 같은 모두를 골라야 합니다.
💼 이 유형을 다시 만나면"가장 ~한 것 하나" 문제에서 동점 처리를 물어보는지를 반드시 확인하세요. 문제에 "동점자가 없다"는 조건이 있으면 LIMIT 1이 안전하고, 없으면 동점을 모두 반환하는 방식이 필요할 수 있습니다.
10회차
자연수 뒤집어 배열로 만들기
JAVA2026.09.04
한 줄 요약자릿수 세기와 뒤에서부터 뽑기 로직은 맞았지만, long인 a % 10을 int 배열에 넣으면서 (int) 형변환을 빠뜨려 컴파일 에러가 났다.
쉽게 말하면큰 통(long)의 물을 작은 컵(int)에 부을 땐 넘칠 수 있음을 안다는 표시 (int)를 붙여야 한다. 끝자리부터 떼어 담으면 숫자는 저절로 뒤집힌다.
자연수 n을 뒤집어 각 자리 숫자를 원소로 갖는 배열로 만들어 반환하는 문제. n이 최대 100억이라 long으로 받아야 한다.
JAVA제출 코드
class Solution {
public int[] solution(long n) {
int size = 0;
long asd = n;
while(asd>0){
asd = asd / 10;
size ++;
}
System.out.println(size);
int[] answer = new int[size];
long a = n;
for(int i=0;i<size;i++){
answer[i] =a%10;
a = a/10;
}
return answer;
}
}
💡 정답 풀이
(int)를 안 붙여서 컴파일 에러가 났다. 로직은 완전히 맞았고 형변환 한 군데만 빠진 경우다.
a가 long이므로 a % 10의 결과도 long이 된다. 이걸 int[] 배열에 그대로 넣으려니 값이 잘릴 수 있다며 컴파일러가 막은 것이다.
[정답 코드]
class Solution {
public int[] solution(long n) {
int size = 0;
long temp = n;
// 1. 10으로 계속 나누며 자릿수를 센다
while (temp > 0) {
temp = temp / 10;
size++;
}
int[] answer = new int[size];
// 2. 일의 자리부터 뽑아 앞에서부터 채운다 (자연스럽게 뒤집힌다)
for (int i = 0; i < size; i++) {
answer[i] = (int)(n % 10); // ← 여기 (int) 가 핵심
n = n / 10;
}
return answer;
}
}
풀이 흐름
1. 자릿수 세기 — 10으로 계속 나눠 몇 번 만에 0이 되는지 세면 그것이 자릿수이고, 곧 배열 크기다.
2. 일의 자리부터 뽑기 — % 10은 항상 맨 뒤 숫자를 준다. 이걸 배열 0번 칸부터 채우면 저절로 역순이 된다.
3. 쓴 자리 버리기 — / 10으로 일의 자리를 없애면 십의 자리가 새 일의 자리가 된다.
4. 형변환 — long 결과를 int 배열에 넣으려면 (int)로 명시적 강제 형변환(축소 변환)이 필요하다.
📌 주요 개념 정리
(1) 자료형 범위 — int vs longint는 약 21억까지만 담을 수 있다. 문제에서 n이 최대 100억이므로 오버플로를 피하려면 long으로 받아야 한다.
(2) 강제 형변환(축소 변환)long 변수와 정수를 연산하면 결과도 long이다. 8바이트 long을 4바이트 int 자리에 넣으면 값 손실 가능성 때문에 컴파일 에러가 나므로, (int)를 붙여 "손실을 감수하겠다"고 명시해야 한다.
(3) 자릿수 추출의 수학적 원리
· n % 10 → 일의 자리 숫자를 뽑는다
· n / 10 → 일의 자리를 버린다
이 둘을 반복하면 뒤에서부터 차례로 숫자를 꺼낼 수 있다.
(4) 반복문 탈출 조건
정수를 10으로 계속 나누면 결국 0이 된다. 조건을 >= 0으로 두면 0 / 10 = 0이 반복돼 무한 루프에 빠지므로 반드시 > 0으로 써야 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
long 값을 int 배열에 넣을 때 (int)가 필요한 이유는?
8바이트를 4바이트에 담으면 값이 잘릴 수 있기 때문입니다. 컴파일러가 "손실 가능성이 있다"며 막고, 개발자가 (int)로 "알고 있다"고 표시해야 통과시킵니다. 다만 실제로 범위를 넘으면 캐스팅해도 값은 깨집니다.
이 문제에서 n을 long으로 받아야 하는 이유는?
문제 조건에 n이 최대 100억이라고 나오는데, int는 약 21억까지만 담기 때문입니다. 문제의 제한 조건이 곧 자료형을 고르라는 힌트입니다.
💼 이 유형을 다시 만나면문제의 제한 조건(범위)을 먼저 읽고 자료형을 정하는 것이 코딩테스트의 기본기입니다. "10^9 이하"면 int로 충분하지만 "10^10"이면 long이어야 하죠. 누적 합계는 입력이 int라도 long으로 받는 습관이 안전합니다.
10회차
재구매가 일어난 상품과 회원 리스트 구하기
SQL2026.09.04
한 줄 요약GROUP BY USER_ID, PRODUCT_ID로 회원·상품 쌍을 묶고 HAVING COUNT(*)로 2건 이상인 쌍만 남긴다.
쉽게 말하면영수증을 회원·상품 짝으로 묶었을 때 같은 짝이 두 장 이상이면 재구매다. 장수는 묶은 뒤에야 알 수 있어 WHERE가 아니라 HAVING에서 거른다.
온라인 판매 테이블에서 같은 회원이 같은 상품을 2번 이상 구매한(재구매) 조합을 찾아, 회원 ID 오름차순·상품 ID 내림차순으로 조회하는 문제.
SQL제출 코드
-- 제출하지 못했다 (시간 내 작성 실패)
💡 정답 풀이
SELECT USER_ID, PRODUCT_ID
FROM ONLINE_SALE
GROUP BY USER_ID, PRODUCT_ID
HAVING COUNT(*) >= 2
ORDER BY USER_ID ASC, PRODUCT_ID DESC;
풀이 흐름
1. 그룹 묶기 — GROUP BY USER_ID, PRODUCT_ID로 두 컬럼을 한 쌍으로 묶는다. 그러면 "1번 회원이 3번 상품을 산 기록"끼리 한 덩어리가 된다.
2. 재구매 필터링 — 그 덩어리 안의 행 수가 2 이상이면 같은 상품을 두 번 이상 산 것이므로 HAVING COUNT(*) >= 2로 거른다.
3. 정렬 — ORDER BY USER_ID ASC, PRODUCT_ID DESC로 회원 오름차순, 같은 회원 안에서는 상품 내림차순.
핵심은 WHERE가 아니라 HAVING이라는 점이다. "몇 번 샀는지"는 묶어 봐야 알 수 있는 값(집계)이므로, 묶기 전에 동작하는 WHERE에는 COUNT를 쓸 수 없다.
📌 주요 개념 정리
(1) 다중 컬럼 GROUP BYGROUP BY 뒤에 컬럼을 쉼표로 나열하면 나열한 값들의 조합이 같은 행끼리 묶인다. 한 회원이 여러 상품을 샀더라도 회원과 상품을 함께 묶으면 (1번 회원, 3번 상품), (1번 회원, 4번 상품)처럼 고유한 구매 조합별로 집계된다.
(2) HAVING과 WHERE의 차이 (다시 확인)
· WHERE — 묶기 전 개별 행을 거른다. 집계 함수를 쓸 수 없다.
· HAVING — 묶은 뒤 그룹을 거른다. 집계 함수를 쓸 수 있다.
"그룹 안의 행이 2개 이상"은 COUNT가 필요하므로 반드시 HAVING이다. → 🗄️ 19. HAVING vs WHERE(3) 복합 정렬ORDER BY에 기준을 여러 개 둘 때는 쉼표로 구분하며, 가장 왼쪽이 1차 기준이다. 값이 같은 행들에 한해서만 다음 기준이 적용된다. ASC는 오름차순(기본값), DESC는 내림차순이며 기준마다 방향을 따로 지정할 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
'같은 회원이 같은 상품을 2번 이상'을 어떻게 표현하나?
두 컬럼을 함께 묶고(GROUP BY USER_ID, PRODUCT_ID) 그 그룹의 행 수를 세면 됩니다. HAVING COUNT(*) >= 2가 "그 조합이 2건 이상"을 뜻하죠. 다중 컬럼 GROUP BY가 핵심입니다.
이 조건을 WHERE로 쓸 수 없는 이유는?
"몇 번 샀는지"는 묶어 봐야 알 수 있는 값이기 때문입니다. WHERE는 개별 행을 볼 뿐 그 행이 몇 번째인지 알지 못합니다. 집계 결과에 조건을 걸려면 HAVING이어야 합니다.
💼 이 유형을 다시 만나면다중 컬럼 GROUP BY + HAVING COUNT는 "중복 찾기" 유형의 표준 해법입니다 — 재구매 회원, 중복 등록, 동일 IP 다중 접속 탐지가 전부 같은 패턴이죠. 실무 데이터 검증에서도 자주 씁니다.
11회차
리스트 자르기
JAVA2026.09.08
한 줄 요약원본용 인덱스 i와 결과용 idx를 따로 두고, b번 인덱스까지 포함해 자르며, n=2는 끝까지 자르고 마지막 분기는 else로 닫아야 한다.
쉽게 말하면책의 5쪽부터 오려 새 공책에 붙일 때 새 공책은 1쪽부터 채운다. 원본 쪽수와 공책 쪽수를 따로 세야 한다.
정수 n과 [a, b, c]가 담긴 slicer, 그리고 num_list가 주어질 때 n의 값(1~4)에 따라 서로 다른 규칙으로 리스트를 잘라 반환하는 문제.
JAVA제출 코드
class Solution {
public int[] solution(int n, int[] slicer, int[] num_list) {
int[] result;
if(n==1){
result = new int[slicer[1]];
for(int i=0;i<slicer[1];i++){
result[i] = num_list[i];
}
}
else if(n==2){
result = new int[slicer[2]-slicer[0]];
for(int i=slicer[0];i<slicer[2];i++){
result[i] = num_list[i];
}
}
else if(n==3){
result = new int[slicer[1]-slicer[0]];
for(int i=slicer[0];i<slicer[1];i++){
result[i] = num_list[i];
}
}
else if(n==4){
result = new int[slicer[1]-slicer[0]];
for(int i=slicer[0];i<slicer[1];i+=slicer[2]){
result[i] = num_list[i];
}
}
return result;
}
}
💡 정답 풀이
실수가 네 가지 겹쳤다.
1. 결과 배열 인덱스를 원본 인덱스로 썼다 — result[i] = num_list[i]에서 i가 slicer[0]부터 시작하면 result의 앞부분은 0으로 비고 뒤쪽은 범위를 넘어간다. 원본용 i와 결과용 idx를 따로 둬야 한다.
2. 끝 인덱스를 포함하지 않았다 — 문제의 "b번 인덱스까지"는 b를 포함하므로 i < b가 아니라 i <= b다.
3. n=2 분기를 잘못 읽었다 — slicer[2]는 끝 인덱스가 아니라 간격 c다. n=2는 "a번부터 끝까지"이므로 num_list.length가 기준이다.
4. 모든 분기에서 초기화가 보장되지 않았다 — else if(n==4)로 끝나면 컴파일러가 "n이 그 외의 값이면 result가 초기화되지 않는다"고 판단해 컴파일 에러를 낸다. 마지막은 else여야 한다.
[정답 1 — 분기별로 고친 버전]
class Solution {
public int[] solution(int n, int[] slicer, int[] num_list) {
int a = slicer[0], b = slicer[1], c = slicer[2];
int[] result;
int idx = 0; // 결과 배열 전용 인덱스
if (n == 1) {
result = new int[b + 1];
for (int i = 0; i <= b; i++) result[idx++] = num_list[i];
} else if (n == 2) {
result = new int[num_list.length - a];
for (int i = a; i < num_list.length; i++) result[idx++] = num_list[i];
} else if (n == 3) {
result = new int[b - a + 1];
for (int i = a; i <= b; i++) result[idx++] = num_list[i];
} else { // else 로 닫아야 초기화가 보장된다
result = new int[(b - a) / c + 1];
for (int i = a; i <= b; i += c) result[idx++] = num_list[i];
}
return result;
}
}
[정답 2 — 시작·끝·간격만 정하고 루프는 하나로]
class Solution {
public int[] solution(int n, int[] slicer, int[] num_list) {
int start = (n == 1) ? 0 : slicer[0];
int end = (n == 2) ? num_list.length - 1 : slicer[1];
int step = (n == 4) ? slicer[2] : 1;
int[] result = new int[(end - start) / step + 1];
int idx = 0;
for (int i = start; i <= end; i += step) {
result[idx++] = num_list[i];
}
return result;
}
}
2번 방식의 발상 — n에 따라 달라지는 것은 시작·끝·간격 세 가지뿐이다. 그 셋만 삼항 연산자로 먼저 정하면 반복문은 하나로 통일되고, 배열 크기도 (end - start) / step + 1 공식 하나로 끝난다.
📌 주요 개념 정리
(1) 자바 지역 변수의 초기화 보장
메서드 안의 지역 변수는 자동으로 기본값이 들어가지 않는다. 조건문 안에서만 값을 넣는다면 어떤 경로로 와도 반드시 할당되도록 선언할 때 초기화하거나 else로 닫아야 한다. 안 그러면 variable might not have been initialized 컴파일 에러가 난다.
(2) 원본 인덱스와 결과 인덱스의 분리
배열을 잘라 담을 때 원본의 시작점(a)과 결과의 시작점(0)은 다르다. 원본 인덱스를 결과 배열에 그대로 쓰면 앞부분이 0으로 비거나 범위를 벗어난다. idx++처럼 결과 전용 인덱스를 따로 두는 것이 정석이다.
(3) 닫힌 구간의 개수 계산
· a부터 b까지 양 끝을 모두 포함하면 개수는 b - a + 1
· c 간격으로 건너뛰면 개수는 (b - a) / c + 1(4) 오프 바이 원(off-by-one) 방지
"~미만"인지 "~이하"인지에 따라 <와 <=를 정확히 구분해야 마지막 원소가 빠지거나 하나 더 들어가는 실수를 막을 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
원본 인덱스와 결과 인덱스를 분리해야 하는 이유를 설명해보세요.
시작점이 다르기 때문입니다. 원본은 a번부터 읽지만 결과 배열은 항상 0번부터 채워야 하죠. result[i] = num_list[i]로 쓰면 앞부분이 0으로 비거나 범위를 넘습니다. idx++로 결과 전용 인덱스를 따로 두는 것이 정석입니다.
n의 네 경우를 하나의 반복문으로 합칠 수 있는 이유는?
달라지는 것이 시작·끝·간격 세 가지뿐이기 때문입니다. 그 셋만 삼항 연산자로 먼저 정하면 반복문은 하나로 통일되죠. "무엇이 변하고 무엇이 같은지"를 분리하는 사고가 코드를 단순하게 만듭니다.
💼 이 유형을 다시 만나면"경우가 여러 개인데 구조는 같다"면 변하는 값만 뽑아내는 리팩터링을 떠올리세요. 실무에서도 비슷한 분기가 반복되면 파라미터로 뽑아 하나로 합치는 것이 기본 접근입니다. 코드가 짧아지면 버그도 줄어듭니다.
11회차
월별 잡은 물고기 수 구하기
SQL2026.09.08
한 줄 요약날짜에서 월만 MONTH(TIME)으로 뽑아 그 식으로 GROUP BY하고 COUNT(*)로 월별 마릿수를 센다.
쉽게 말하면낚시 일지를 날짜가 아니라 몇 월인지만 보고 달력 칸에 나눠 넣은 뒤, 칸마다 몇 마리인지 센다.
물고기 정보 테이블에서 월별로 잡은 물고기 수를 세어, 월을 기준으로 오름차순 정렬해 조회하는 문제.
SQL제출 코드
select count(*) as FISH_COUNT, month(TIME) as MONTH
from FISH_INFO
group by month(time)
order by month;
💡 정답 풀이
통과한 코드다. 핵심은 날짜에서 '월'만 뽑아내는 방법을 아는 것이었다.
풀이 흐름
1. MONTH() 함수 — TIME은 날짜 타입이라 프로그래밍 언어처럼 TIME.month로 접근할 수 없다. SQL 내장 함수 MONTH(TIME)으로 월을 추출한다.
2. 출력 형식 자동 충족 — MONTH()는 01, 02가 아니라 1, 2 같은 정수를 돌려준다. 문제의 "9 이하는 두 자리로 출력하지 않는다"는 조건이 저절로 맞는다.
3. 그룹화 — 월별로 세야 하므로 GROUP BY MONTH(TIME). 가공한 식으로도 묶을 수 있다는 점이 포인트다.
4. 카운트 — COUNT(*)로 각 월 그룹의 행 수를 세고 FISH_COUNT로 별칭을 단다.
5. 정렬 — ORDER BY MONTH. ASC가 기본값이라 생략할 수 있다.
📌 주요 개념 정리
(1) 날짜 추출 내장 함수
날짜에서 특정 단위를 뽑을 때는 전용 함수를 쓴다.
· YEAR(날짜) → 연도 / MONTH(날짜) → 월 / DAY(날짜) → 일 (모두 정수로 반환)
· 두 자리 문자열('03')이 필요하면 DATE_FORMAT(날짜, '%m')을 쓴다.
(2) SQL에서 점(.)의 의미
SQL의 점은 객체의 속성 접근자가 아니다. 테이블명.컬럼명을 구분하는 용도다. TIME.month라고 쓰면 DB는 TIME이라는 테이블의 month 컬럼을 찾으려 하므로 Unknown column 에러가 난다.
(3) 가공한 식으로 GROUP BYGROUP BY에는 컬럼뿐 아니라 함수를 적용한 결과도 쓸 수 있다. GROUP BY MONTH(TIME), GROUP BY YEAR(YM)처럼. → 🗄️ 18. GROUP BY(4) ORDER BY 기본 동작ASC(오름차순)가 기본값이라 생략할 수 있다. 내림차순은 DESC를 명시한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
SQL에서 TIME.month 처럼 점을 찍으면 왜 안 되나?
SQL의 점은 객체 속성 접근자가 아니라 '테이블명.컬럼명' 구분자이기 때문입니다. TIME.month는 TIME이라는 테이블의 month 컬럼을 찾으라는 뜻이 되어 Unknown column 에러가 납니다. 날짜에서 값을 뽑으려면 함수를 써야 합니다.
MONTH() 결과가 '01'이 아니라 '1'인 것이 이 문제에서 왜 유리한가?
문제 조건이 "9 이하는 두 자리로 출력하지 않는다"였기 때문입니다. MONTH()는 정수를 돌려주므로 앞의 0이 자동으로 없어 조건이 저절로 충족됩니다. 두 자리가 필요했다면 DATE_FORMAT(날짜, '%m')을 써야 했겠죠.
💼 이 유형을 다시 만나면날짜에서 단위를 뽑는 함수(YEAR·MONTH·DAY)와 형식을 만드는 함수(DATE_FORMAT)를 구분해 두면 좋습니다. 전자는 정수, 후자는 문자열을 돌려주므로 정렬과 출력 형식이 달라집니다.
12회차
수열과 구간 쿼리 3
JAVA2026.09.09
한 줄 요약쿼리마다 두 인덱스를 꺼내 임시 변수 temp로 값을 맞바꾸는 swap 정석으로 풀었다.
쉽게 말하면두 컵의 음료를 바꾸려면 빈 컵 하나가 더 필요하다. temp가 그 빈 컵이다.
정수 배열 arr와 [i, j] 쌍이 담긴 queries가 주어질 때, 각 쿼리마다 arr[i]와 arr[j]의 값을 서로 바꾼 뒤 최종 배열을 반환하는 문제.
JAVA제출 코드
class Solution {
public int[] solution(int[] arr, int[][] queries) {
int t = arr.length;
int[] answer = new int[arr.length];
for(int i=0;i<arr.length;i++){
answer[i]=arr[i];
}
for(int i=0;i<queries.length;i++){
int first = queries[i][0];
int second = queries[i][1];
int temp = answer[first];
answer[first] = answer[second];
answer[second] = temp;
}
return answer;
}
}
💡 정답 풀이
통과한 코드다. swap의 정석을 정확히 지켰다.
풀이 흐름
1. 원본 복사 — arr를 건드리지 않으려고 같은 크기의 answer를 만들어 값을 옮겼다.
2. 쿼리 순회 — queries[i][0]과 queries[i][1]로 교환할 두 인덱스를 꺼낸다.
3. 임시 변수로 교환 — temp에 한쪽을 먼저 담아 둬야 덮어써도 값이 사라지지 않는다.
더 짧게 쓸 수 있는 부분
· 복사는 int[] answer = arr.clone(); 한 줄로 끝난다.
· 순회는 향상된 for문이 더 읽기 좋다:
for (int[] q : queries) { ... q[0], q[1] ... }
· 문제가 원본 수정을 막지 않으므로 복사 없이 arr를 직접 바꿔 반환해도 된다
(추가 배열 생성 비용을 아낄 수 있다).
[정리한 코드]
class Solution {
public int[] solution(int[] arr, int[][] queries) {
for (int[] q : queries) {
int temp = arr[q[0]];
arr[q[0]] = arr[q[1]];
arr[q[1]] = temp;
}
return arr;
}
}
참고로 int t = arr.length;는 선언만 하고 쓰지 않은 변수다. 지우는 편이 깔끔하다.
📌 주요 개념 정리
(1) 값 교환(swap)에 임시 변수가 필요한 이유a = b; b = a;처럼 쓰면 첫 줄에서 a의 원래 값이 이미 사라져 둘 다 b가 된다. 그래서 덮어쓰기 전에 한쪽을 temp에 보관하는 3단계가 필요하다.
자바에는 파이썬의 a, b = b, a 같은 동시 대입이 없으므로 이 방식이 정석이다.
(2) 2차원 배열 접근 — queries[i][j]
· i는 몇 번째 쿼리인지(행), j는 그 쿼리 안의 위치(열)를 가리킨다.
· 향상된 for문 for (int[] q : queries)를 쓰면 q[0], q[1]로 더 직관적으로 쓸 수 있다.
(3) 제자리(in-place) 연산 vs 복사
문제가 원본 배열의 수정을 막지 않는다면, 매개변수로 받은 배열을 직접 바꿔 반환하는 편이 메모리와 시간을 아낀다. 반대로 원본을 보존해야 하거나 여러 번 재사용해야 한다면 clone()으로 복사한 뒤 다루는 것이 안전하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
임시 변수 없이 두 값을 바꾸려 하면 왜 실패하는지 설명해보세요.
a = b;를 하는 순간 a의 원래 값이 덮여 사라지기 때문입니다. 다음 줄 b = a;는 이미 b가 된 a를 넣는 셈이라 둘 다 b가 됩니다. 그래서 덮어쓰기 전에 한쪽을 보관하는 temp가 필요합니다.
이 문제에서 배열을 복사하지 않고 arr를 직접 바꿔도 되는 이유는?
문제가 원본 보존을 요구하지 않기 때문입니다. 매개변수로 받은 배열을 직접 고쳐 반환하면 추가 배열 생성 비용을 아낍니다. 다만 실무에서는 넘겨받은 것을 함부로 고치면 호출한 쪽이 놀라므로, 보통은 복사하거나 최소한 문서에 명시합니다.
💼 이 유형을 다시 만나면swap은 정렬·셔플·회전 등 수많은 알고리즘의 기본 조각입니다. 2차원 배열을 for (int[] q : queries)로 순회하는 패턴도 자주 나오니 손에 익혀 두면 좋습니다.
12회차
특정 조건을 만족하는 물고기별 수와 최대 길이 구하기
SQL2026.09.09
한 줄 요약10cm 이하는 NULL로 저장되므로 AVG(IFNULL(LENGTH, 10))처럼 NULL을 10으로 채워 평균을 내야 하며, AVG는 NULL을 분모에서도 뺀다.
쉽게 말하면길이를 안 적은 물고기를 빼고 평균을 내면 평균이 부풀려진다. 빈칸에 10을 적어 넣은 뒤 계산해야 한다.
물고기 종류별로 마리 수와 최대 길이를 구하되, 10cm 이하(테이블에는 NULL로 저장됨)를 10으로 쳐서 계산한 평균 길이가 33cm 이상인 종류만 조회하는 문제.
SQL제출 코드
select count(*) as FISH_COUNT, max(LENGTH) as MAX_LENGTH, FISH_TYPE
from FISH_INFO
group by FISH_TYPE
having avg(case
when length <= 10 then 10
else length
end) >= 33
order by FISH_TYPE asc;
💡 정답 풀이
틀린 곳은 하나 — LENGTH IS NULL을 빠뜨렸다.
문제 설명에 10cm 이하는 LENGTH가 NULL로 저장된다고 되어 있다. 즉 테이블에는 10 이하의 숫자가 아예 없고 전부 NULL이다. 그런데 제출 코드의 조건은 length <= 10뿐이라 NULL은 걸리지 않고ELSE length로 빠져 그대로 NULL이 된다. 그리고 AVG는 NULL을 분모에서도 빼 버리므로 평균이 실제보다 높게 나온다.
[정답 1 — IFNULL]
SELECT COUNT(*) AS FISH_COUNT, MAX(LENGTH) AS MAX_LENGTH, FISH_TYPE
FROM FISH_INFO
GROUP BY FISH_TYPE
HAVING AVG(IFNULL(LENGTH, 10)) >= 33
ORDER BY FISH_TYPE ASC;
테이블에 10 이하가 NULL로만 존재하므로, NULL을 10으로 바꿔 주는 것만으로 "10 이하는 10으로 계산" 조건이 정확히 충족된다. COALESCE(LENGTH, 10)도 같다.
[정답 2 — CASE WHEN]
HAVING AVG(CASE WHEN LENGTH IS NULL OR LENGTH <= 10 THEN 10 ELSE LENGTH END) >= 33
NULL과 10 이하를 모두 명시해 두면, 나중에 8·9 같은 실제 숫자가 데이터로 들어와도 안전하다. 테이블의 저장 방식에 덜 의존하는 쪽이라 방어적으로는 이쪽이 낫다.
주의 — CASE를 GROUP BY와 HAVING 사이에 따로 떼어 놓으면 문법 에러다. CASE는 절(clause)이 아니라 값을 만드는 식(expression)이므로 AVG() 괄호 안에 들어가야 한다.
📌 주요 개념 정리
(1) 집계 함수와 NULL — 분모에서도 빠진다AVG(컬럼)은 NULL인 행을 합계(분자)에서만이 아니라 개수(분모)에서도 제외한다. 그래서 NULL이 많으면 평균이 실제보다 높게 나온다. 문제에서 "특정 값으로 쳐서 평균을 내라"고 하면 반드시 집계 함수 안에서 NULL을 채운 뒤 계산해야 한다.
(2) IFNULL(값, 대체값)
값이 NULL이면 대체값을, 아니면 원래 값을 그대로 돌려주는 MySQL 함수다. 표준 SQL인 COALESCE도 같은 일을 하며 어느 DB에서나 동작한다.
(3) CASE는 '절'이 아니라 '식'이다WHERE·GROUP BY·ORDER BY는 절(clause)이라 문장의 정해진 자리에 오지만, CASE는 값 하나를 돌려주는 식이다. 따라서 값이 들어갈 수 있는 자리(SELECT 목록, WHERE 조건식 안, 집계 함수 괄호 안)에만 쓸 수 있다.
(4) WHERE와 HAVING의 차이 (다시)WHERE는 묶기 전 개별 행을, HAVING은 묶은 뒤 그룹을 거른다. "종류별 평균 길이가 33 이상"은 묶어 봐야 알 수 있는 값이므로 HAVING이다. → 🗄️ 19. HAVING vs WHERE
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
AVG가 NULL을 '분모에서도' 뺀다는 것이 왜 문제가 되나?
값이 10개인데 그중 4개가 NULL이면 6으로 나눈 평균이 나오기 때문입니다. "NULL을 10으로 쳐서 평균을 내라"는 요구라면 10으로 나눠야 맞죠. 그래서 IFNULL로 값을 채운 뒤 평균을 내야 분모까지 제대로 잡힙니다.
CASE를 GROUP BY와 HAVING 사이에 따로 쓰면 왜 에러인가?
CASE는 WHERE·GROUP BY 같은 절(clause)이 아니라 값을 만드는 식(expression)이기 때문입니다. 값이 들어갈 자리에만 놓을 수 있죠. 이 문제에서는 AVG() 괄호 안이 그 자리입니다.
💼 이 유형을 다시 만나면"NULL을 특정 값으로 쳐서 집계하라"는 코딩테스트 SQL의 단골 함정입니다. 조건에 "~인 경우 NULL로 저장된다"는 문장이 보이면 즉시 IFNULL이나 CASE를 집계 함수 안에 넣을 준비를 하세요.
14회차
이진 변환 반복하기
프로그래밍2026.09.14
한 줄 요약0을 지운 뒤 남은 문자열 자체가 아니라 그 길이를 Integer.toBinaryString으로 바꾸고, 지운 0의 개수는 replace 전후 길이 차로 센다.
쉽게 말하면체로 0을 걸러 내고 남은 양(길이)을 재서 그 숫자를 2진수로 다시 적는 일을, 1만 남을 때까지 반복한다. 걸러진 양은 전후 길이 차이로 안다.
0과 1로 된 문자열에서 0을 모두 지우고, 남은 길이를 2진수로 바꾸는 과정을 문자열이 "1"이 될 때까지 반복해, 반복 횟수와 지운 0의 총개수를 돌려주는 문제.
JAVA제출 코드
// 시간 내에 제출하지 못했다
💡 정답 풀이
핵심은 "무엇을 2진수로 바꾸는가"다.
문제를 처음 읽으면 남은 문자열 자체를 2진수로 바꾸는 것으로 착각하기 쉽다. 그게 아니라 남은 문자열의 '길이'를 2진수 문자열로 바꾸는 것이다. 이 한 줄을 잘못 읽으면 전체가 어긋난다.
[정답 코드]
class Solution {
public int[] solution(String s) {
int transformCount = 0; // 이진 변환 횟수
int zeroCount = 0; // 제거된 0의 총개수
while (!s.equals("1")) { // "1"이 될 때까지
int originalLength = s.length();
s = s.replace("0", ""); // ① 0 제거
zeroCount += originalLength - s.length(); // ② 줄어든 만큼이 지운 0의 수
int c = s.length(); // ③ 남은 '길이'를
s = Integer.toBinaryString(c);// 2진수 문자열로
transformCount++; // ④ 변환 1회
}
return new int[]{transformCount, zeroCount};
}
}
지운 0의 개수를 세는 요령 — 따로 세지 않고 길이의 차이로 구한다. replace 전후 길이를 빼면 그게 곧 사라진 0의 개수다. 반복문으로 하나씩 세는 것보다 짧고 실수도 적다.
왜 equals인가 — s == "1"로 쓰면 무한 루프에 빠진다. Integer.toBinaryString이 만든 문자열은 새 객체라 리터럴 "1"과 주소가 다르기 때문이다. 실행 결과에서 직접 확인할 수 있다.
실행 결과정답 코드를 돌려 변환 과정을 한 회차씩 추적 먼저 예측 → 펼쳐서 확인
입력 s = "110010101001"
1회차: "110010101001" -0제거-> "111111" (0을 6개 뺌, 길이 6) -2진수-> "110"
2회차: "110" -0제거-> "11" (0을 1개 뺌, 길이 2) -2진수-> "10"
3회차: "10" -0제거-> "1" (0을 1개 뺌, 길이 1) -2진수-> "1"
결과 = [3, 8] ← 문제의 기댓값과 일치
입력 s = "01110"
1회차: "01110" -0제거-> "111" (0을 2개 뺌, 길이 3) -2진수-> "11"
2회차: "11" -0제거-> "11" (0을 0개 뺌, 길이 2) -2진수-> "10"
3회차: "10" -0제거-> "1" (0을 1개 뺌, 길이 1) -2진수-> "1"
결과 = [3, 3] ← 기댓값과 일치
== 여기서 자주 틀린다 ==
replace 는 원본을 바꾸지 않는다: s=1100, 반환값=11
Integer.toBinaryString(1).equals("1") = true
Integer.toBinaryString(1) == "1" = false ← == 로 쓰면 루프가 끝나지 않는다
Integer.toBinaryString(5) = 101
Integer.toBinaryString(1) = 1
2회차를 보면 "11"에서 0을 0개 뺐는데도 변환 횟수는 올라갑니다. "0을 지웠을 때만 1회"가 아니라 2진수로 바꿀 때마다 1회이기 때문입니다. 문제를 대충 읽으면 여기서 개수가 하나 모자라게 나옵니다.
그리고 Integer.toBinaryString(1) == "1"이 false인 것이 결정적입니다. Integer.toBinaryString(1)은 내용이 "1"인 새 String 객체를 만들어 돌려주는데, ==는 내용이 아니라 주소를 비교하므로 영원히 false죠. 그러면 while이 끝나지 않고 시간 초과가 납니다. → ☕ 09. String과 문자열 비교
📌 주요 개념 정리
2진수로 바꾸는 대상은 남은 문자열이 아니라 그 '길이'다 — 문제를 정확히 읽는 것이 절반.
지운 0의 개수는 replace 전후의 길이 차로 구하면 간단하다.
변환 횟수는 0을 지웠는지와 무관하게, 2진수로 바꿀 때마다 1 올라간다.
문자열 비교는 반드시 equals — ==는 주소를 비교해 무한 루프가 된다.
String은 불변이라 s.replace(...)의 반환값을 다시 대입해야 반영된다.
Integer.toBinaryString(int)이 10진수 → 2진수 문자열을 대신 해 준다(직접 나눌 필요 없다).
s.length()는 메서드(괄호 O), arr.length는 필드(괄호 X).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
s == "1" 로 쓰면 정확히 어떤 일이 벌어지나?
무한 루프입니다. Integer.toBinaryString(1)은 내용이 "1"인 새 객체를 만들어 돌려주는데, ==는 내용이 아니라 주소를 비교하므로 값이 같아도 false죠. 조건이 영원히 참이라 반복이 끝나지 않고 시간 초과로 실패합니다.
"11"처럼 0이 하나도 없는 문자열이 들어와도 변환 횟수가 올라가는 이유는?
변환의 기준이 '0을 지웠는가'가 아니라 '2진수로 바꿨는가'이기 때문입니다. 0이 0개여도 길이 2를 "10"으로 바꾸는 변환은 일어났으므로 1회입니다. 여기서 헷갈리면 답이 1씩 모자라게 나옵니다.
💼 이 유형을 다시 만나면문자열을 반복해서 줄여 나가는 유형은 코딩테스트에 자주 나옵니다. 공통 요령은 두 가지예요 — ① 종료 조건을 문자열 비교로 쓸 때는 무조건 equals, ② 개수를 셀 때는 하나씩 세지 말고 길이 차이를 이용. 그리고 진법 변환은 Integer.toBinaryString · Integer.toString(n, 진법) · Integer.parseInt(s, 진법) 세 개를 외워 두면 직접 구현할 일이 거의 없습니다.
14회차
자동차 종류 별 특정 옵션이 포함된 자동차 수 구하기
SQL2026.09.14
한 줄 요약OR 양쪽은 각각 완성된 조건이어야 하므로 OPTIONS LIKE를 매번 다시 써야 하며, 생략하면 MySQL·MariaDB는 에러 없이 틀린 결과를 낸다.
쉽게 말하면옵션에 통풍시트가 있나, 열선시트, 가죽시트라고 물으면 뒤 두 개는 질문이 아니라 단어일 뿐이다. 조건마다 OPTIONS LIKE를 다시 붙여 물어야 한다.
OPTIONS 컬럼에 '통풍시트'·'열선시트'·'가죽시트' 중 하나라도 들어 있는 차를 자동차 종류별로 세어, 종류 오름차순으로 조회하는 문제.
SQL제출 코드
select CAR_TYPE, count(*) as CARS
from CAR_RENTAL_COMPANY_CAR
where OPTIONS LIKE '%통풍시트%' OR '%열선시트%' OR '%가죽시트%'
group by CAR_TYPE
order by CAR_TYPE asc;
💡 정답 풀이
틀린 곳은 WHERE 한 줄 — OR 뒤에 컬럼명과 LIKE를 다시 써야 한다.OR의 좌우에는 각각 혼자서 참·거짓이 되는 완성된 조건이 와야 한다. OR '%열선시트%'는 조건이 아니라 그냥 문자열 하나다.
[정답 코드]
SELECT CAR_TYPE, COUNT(*) AS CARS
FROM CAR_RENTAL_COMPANY_CAR
WHERE OPTIONS LIKE '%통풍시트%'
OR OPTIONS LIKE '%열선시트%'
OR OPTIONS LIKE '%가죽시트%'
GROUP BY CAR_TYPE
ORDER BY CAR_TYPE ASC;
[더 짧게 — 정규식]
WHERE OPTIONS REGEXP '통풍시트|열선시트|가죽시트'
|가 OR 역할을 해서 한 줄로 줄어든다. 다만 인덱스를 못 타는 것은 LIKE '%...%'와 마찬가지이고, 팀에 따라 가독성 때문에 LIKE 나열을 선호하기도 한다.
⚠️ 여기가 이 문제의 진짜 무서운 점이다. 오답노트에는 "문법 에러가 발생한다"고 적혀 있는데, MySQL·MariaDB에서는 에러가 나지 않는다. 조용히 실행되고 답만 틀린다. 실행 결과에서 직접 확인해 보자.
실행 결과같은 데이터에 두 쿼리를 나란히 돌려 비교 (MariaDB 12.3에서 실측) 먼저 예측 → 펼쳐서 확인
GROUP BY CAR_TYPE + COUNT(*) = 종류별로 묶어 그 안의 행 수를 센다.
정렬 대상이 문자열이면 ORDER BY는 가나다순이 된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
OPTIONS LIKE '%A%' OR '%B%' 가 에러가 아니라면, DB는 이걸 어떻게 읽는 걸까?
'%B%'를 숫자로 바꿔 참·거짓으로 판단합니다. 앞에 숫자가 없는 문자열은 0이 되고, SQL에서 0은 거짓이죠. 그래서 조건 전체가 LIKE '%A%' OR 거짓, 즉 첫 조건 하나만 남습니다. 에러가 안 나는 대신 결과가 조용히 줄어듭니다.
에러가 나는 것과 조용히 틀리는 것 중 어느 쪽이 더 위험한가? 왜?
조용히 틀리는 쪽입니다. 에러는 그 자리에서 알려 주니 바로 고칠 수 있지만, 결과가 그럴듯하게 나와 버리면 틀린 줄도 모르고 넘어갑니다. 실무에서는 이런 쿼리가 몇 달 뒤 "숫자가 안 맞는다"는 문의로 돌아오죠. 그래서 결과 건수를 눈으로 확인하는 습관이 중요합니다.
💼 이 유형을 다시 만나면"여러 조건 중 하나라도" 유형에서 가장 많이 나오는 실수입니다. 습관으로 OR를 칠 때마다 컬럼명부터 다시 쓰기를 들이세요. 실무에서도 필터가 여러 개인 검색 기능을 만들 때 똑같은 형태가 나오고, 이때 조건이 하나 빠지면 검색 결과가 조용히 누락됩니다. 리뷰에서 OR 뒤에 컬럼명이 없는 줄이 보이면 바로 지적 대상입니다.
15회차
핸드폰 번호 가리기
JAVAchar[]String 불변반복 범위
한 줄 요약뒤쪽 네 글자는 유지하고 앞부분만 별표로 바꾼다. 핵심은 마지막 반복 인덱스를 i < 길이 - 4로 정확히 정하는 것이다.
쉽게 말하면영수증의 카드번호처럼 끝 네 자리만 남기고 앞을 별표로 칠한다. 칠할 범위는 길이에서 4를 뺀 자리 직전까지다.
전화번호 문자열 phone_number(길이 4~20)를 받아 뒤 네 자리만 남기고 나머지를 모두 *로 가린 문자열을 돌려주는 문제. 예: "01033334444" → "*******4444".
JAVA제출 코드
class Solution {
public String solution(String phone_number) {
char[] arr = new char[phone_number.length()];
for(int i=0;i<phone_number.length();i++){
arr[i]=phone_number.charAt(i);
System.out.print(arr[i]);
}
for(int i=0;i<arr.length-4;i++){
arr[i]='*';
}
String ans = "";
for(int i=0;i<arr.length;i++){
ans = ans+arr[i];
}
return ans;
}
}
💡 풀이 흐름
통과한 풀이다 — 배열로 옮기고, 앞부분만 바꾸고, 다시 이어 붙이는 세 단계.
1. 문자열 → 문자 배열 — String은 한번 만들면 글자를 바꿀 수 없다(불변). 그래서 char[]로 옮겨 원소를 바꾼다. 원래 문자열은 그대로 남는다.
2. 앞부분만 치환 — 길이가 n이면 바꿀 위치는 0부터 n−5까지, 즉 i < n - 4다. n=4일 때는 반복이 0회여야 한다. <=로 쓰면 보존할 네 자리 중 첫 글자까지 가린다.
3. 배열 → 문자열 — 제출 코드는 ans = ans + arr[i]로 한 글자씩 이어 붙였다.
고칠 점 하나 — System.out.print(arr[i])는 가리기 전 번호를 콘솔에 남긴다. 채점에는 영향이 없지만, 가리려던 정보가 로그로 새는 셈이니 디버깅이 끝나면 지운다.
아래는 같은 원리를 라이브러리로 간결하게 쓴 것이다. toCharArray()로 새 배열을 만들고 String.valueOf(char[])로 문자들을 하나의 문자열로 만든다.
JAVA같은 원리를 라이브러리로 간결하게 표현
class Solution {
public String solution(String phone_number) {
char[] ch = phone_number.toCharArray(); // ← 원본과 별개인 새 배열
for (int i = 0; i < ch.length - 4; i++) { // ← 뒤 네 칸은 건드리지 않는다
ch[i] = '*';
}
return String.valueOf(ch); // ← 배열 내용을 문자열로
}
}
검증 입력경계값의 결과를 먼저 예측하기 먼저 예측 → 펼쳐서 확인
1234 → 1234 ← 길이 4: 별표 0개
12345 → *2345
01012345678 → *******5678
00000000000000001234 → ****************1234 ← 길이 20: 별표 16개
📌 주요 개념 정리
(1) String은 불변이다charAt(i)로 읽을 수는 있지만 그 자리를 바꾸는 메서드는 없다. 글자를 바꾸려면 char[]나 StringBuilder로 옮겨서 고친 뒤 새 문자열을 만든다. → ☕ 12. String의 특징과 String pool(2) String.valueOf(char[]) vs ch.toString()String.valueOf(ch)는 배열의 문자들을 이어 문자열을 만든다. 반면 ch.toString()은 배열 내용 변환이 아니라 타입과 해시값을 담은 문자열을 돌려준다. → ☕ 13. String 주요 메서드(3) String 누적의 비용ans = ans + c는 매번 이미 만든 앞부분을 다시 복사한 새 문자열을 만들어, 길이가 커질수록 O(n²) 작업이 될 수 있다. 배열 방식은 O(n) 시간·O(n) 추가 공간이다. 이 문제는 입력이 4~20글자로 짧아 통과 여부보다 동작 원리와 가독성 비교가 학습 목표다. 긴 문자열을 반복해 조립할 때는 StringBuilder를 쓴다. → ☕ 15. StringBuffer · StringBuilder(4) 반복 범위는 경계값으로 확인한다
"마지막 몇 개는 빼고"라는 요구는 i < length - k로 옮긴다. 길이가 정확히 k일 때 반복이 0회인지 꼭 따져 본다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
길이가 4이면 별표는 몇 개일까?
0개입니다. i < 4 - 4, 즉 i < 0이 처음부터 거짓이라 반복이 한 번도 돌지 않고 원래 네 글자를 반환합니다. 반복 조건을 <=로 썼다면 여기서 첫 글자가 가려져 틀렸겠죠.
toCharArray로 만든 배열을 바꾸면 원본 String도 함께 바뀔까?
아니요. toCharArray()는 별도의 새 배열을 만들어 줍니다. 문자열은 불변이라 원본은 그대로이고, 바뀐 것은 새 배열뿐입니다.
마지막 줄을 return ch.toString(); 으로 쓰면 어떻게 될까?
별표 번호가 아니라 엉뚱한 문자열이 나옵니다. 배열의 toString()은 내용을 이어 주지 않고 타입과 해시값을 담은 문자열을 돌려주기 때문입니다. 문자 배열을 문자열로 바꿀 때는 String.valueOf(ch)나 new String(ch)를 씁니다.
💼 이 유형을 다시 만나면"문자열의 일부만 바꿔라"는 코딩테스트 문자열 문제의 기본형입니다. String을 직접 고칠 수 없다는 것을 떠올리고 char[]나 StringBuilder로 옮겨서 고친다를 먼저 정하세요. 실무에서는 같은 일이 개인정보 마스킹입니다 — 화면·영수증·로그에 전화번호나 카드번호를 표시할 때 끝 몇 자리만 남기고 가리죠. 이때 가장 흔한 사고가 제출 코드의 System.out.print처럼 가리기 전 원본을 로그에 남기는 것입니다.
정리 작업 메모
제출 코드는 15회차 DOCX 원문에서 그대로 옮겼다. 원래 배지는 "PASS · 제출 기록"이었다.
"검증 입력"의 네 결과는 코드로 결정되는 값이다(길이 4 · 5 · 11 · 20 경계).
이 회차(2026-09-16)는 Back_end/coding-tests/에 날짜 폴더가 없어, 문제 설명은 프로그래머스 문제 조건을 바탕으로 2줄로 정리했다.
15회차 파일
조건에 부합하는 중고거래 상태 조회하기
SQLCASE WHENWHERE vs SELECTDATE 비교
한 줄 요약WHERE는 행을 고르고, SELECT의 CASE는 표시값을 바꾼다. 조건 선택과 값 변환을 분리하고, CASE는 WHEN … THEN … END를 갖춘 식으로 쓴다.
쉽게 말하면WHERE는 손님을 고르는 문지기, SELECT의 CASE는 고른 손님에게 한글 이름표를 달아 주는 안내원이다. 하는 일이 달라 자리도 다르다.
USED_GOODS_BOARD에서 2022년 10월 5일에 등록된 게시글을 골라, 거래 상태 코드(SALE·RESERVED·DONE)를 판매중·예약중·거래완료로 바꿔 보여 주고 게시글 ID 내림차순으로 정렬하는 문제.
SQL제출 코드
SELECT BOARD_ID, WRITER_ID, TITLE, PRICE, STATUS
from USED_GOODS_BOARD
where CREATED_DATE like "2022-10-05"
case STATUS
when 'DONE' = "거래완료"
when 'SALE' = "판매중"
when 'RESERVED' = "예약중"
order by BOARD_ID desc;
💡 정답 풀이
틀린 곳은 CASE 부분 — 세 군데로 나눠 보면 보인다.
① 자리가 틀렸다. 상태를 출력용으로 바꾸는 CASE가 WHERE 뒤에 따로 놓였다. CASE는 조건문을 별도로 실행하는 문장이 아니라 여기서는 값 하나를 계산하는 식(expression)이라, 값이 들어갈 자리인 SELECT 목록 안에 있어야 한다.
② 문법이 틀렸다.WHEN 값 THEN 결과가 필요한 자리에 =를 썼다.
③ 끝이 없다. CASE를 마치는 END가 없다.
[정답 코드 — MySQL 기준]
SELECT BOARD_ID, WRITER_ID, TITLE, PRICE,
CASE STATUS
WHEN 'SALE' THEN '판매중'
WHEN 'RESERVED' THEN '예약중'
WHEN 'DONE' THEN '거래완료'
END AS STATUS
FROM USED_GOODS_BOARD
WHERE CREATED_DATE = '2022-10-05'
ORDER BY BOARD_ID DESC;
풀이 흐름
1. 행 고르기 — WHERE CREATED_DATE = '2022-10-05'로 그날 글만 남긴다. 이 문제의 열은 DATE라 정확한 날짜를 =로 비교한다.
2. 값 바꾸기 — 남은 행마다 CASE STATUS가 상태 코드를 보고 첫 번째로 일치한 WHEN의 결과를 내놓는다.
3. 이름 붙이기 — AS STATUS는 결과 열의 이름일 뿐, DB에 저장된 원본 상태 코드를 바꾸지 않는다.
4. 정렬 — ORDER BY BOARD_ID DESC.
문자열은 작은따옴표로 감싼다. 큰따옴표는 SQL 모드(ANSI_QUOTES)에 따라 문자열이 아니라 컬럼 이름으로 해석될 수 있어서, 그 차이를 처음부터 피하는 것이다.
📌 주요 개념 정리
(1) CASE는 '절'이 아니라 '식'이다WHERE·ORDER BY처럼 문장의 정해진 자리에 오는 절이 아니라, 값 하나를 돌려주는 식이다. 그래서 SELECT 목록·WHERE 조건식 안·집계 함수 괄호 안처럼 값이 들어갈 자리에만 쓴다. (물고기 문제의 AVG(CASE …)와 같은 원리)
(2) 두 가지 모양
· 단순형CASE STATUS WHEN 'SALE' THEN … — 한 컬럼을 여러 값과 같은지 비교할 때.
· 검색형CASE WHEN PRICE >= 10000 THEN … — 범위·복합 조건일 때.
어느 쪽이든 위에서부터 처음 맞는 WHEN 하나만 쓰이고, 아무것도 맞지 않는데 ELSE가 없으면 결과는 NULL이다. Java switch와 비슷하게 분기하지만 break나 fall-through를 그대로 적용하지 마세요. → 🗄️ 27. 내장 함수와 CASE(3) SELECT의 값 변환은 저장값을 바꾸지 않는다
CASE로 바뀌는 것은 결과 화면에 표시되는 값뿐이다. 저장된 값을 바꾸려면 UPDATE가 필요하다.
(4) DATE와 DATETIME
이 문제는 DATE 열이라 =로 충분하다. 실무의 DATETIME 열에 시각이 들어 있다면 CREATED_DATE >= '2022-10-05' AND CREATED_DATE < '2022-10-06'처럼 하루 범위를 잡아야 자정이 아닌 시각의 행을 놓치지 않는다. → 🗄️ 05. WHERE
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
CASE를 WHERE 뒤에 따로 두면 왜 안 되나?
CASE는 값을 만드는 식이지 혼자 실행되는 절이 아니기 때문입니다. "상태를 한글로 보여 달라"는 요구는 출력할 값에 관한 것이니 SELECT 목록 안이 제자리입니다. WHERE는 행을 고르는 일만 합니다.
CASE로 상태를 바꾸면 테이블 데이터도 바뀔까?
아니요. SELECT 결과에 표시되는 값만 바뀝니다. AS STATUS도 결과 열 이름일 뿐이고, 저장된 값을 바꾸려면 UPDATE가 필요합니다.
열이 DATETIME으로 바뀌었는데 = '2022-10-05' 조건을 그대로 쓰면 무엇을 놓칠까?
자정(00:00:00)이 아닌 시각의 행을 놓칠 수 있습니다. 그날 오후 3시에 쓴 글은 '2022-10-05 15:00:00'이라 같지 않죠. 다음 날 미만까지의 범위 조건으로 하루를 표현합니다.
💼 이 유형을 다시 만나면"코드값을 한글 이름으로 바꿔 출력하라"는 코딩테스트 SQL에서 자주 나오는 CASE 문제입니다. 보이면 SELECT 목록 안에 CASE … WHEN … THEN … END AS 별칭을 한 덩어리로 쓰세요. 실무에서도 DB에는 SALE 같은 짧은 코드를 저장하고, 화면에 보여 줄 때 이름으로 바꾸는 일이 흔합니다. 코드 종류가 많아지면 CASE를 늘리기보다 코드와 이름을 담은 별도 테이블과 JOIN하는 방식도 많이 씁니다.
정리 작업 메모
기록 주의: 파일명은 15회차이지만 SQL 표의 날짜·회차는 2026.09.14 / 14회차로 적혀 있다. 이 문제는 기존 카드에 없어 새로 추가하되 시험 날짜를 임의로 고치지 않았다. 그래서 회차 표시는 "15회차 파일"로 두고, 배지에는 표에 적힌 날짜를 썼다.
제출 코드는 DOCX 원문에서 그대로 옮겼다. 원래 배지는 "FAIL · 제출 기록"이었다.
정답 코드는 MySQL 기준으로 정리한 풀이이며 로직은 기존 카드와 같다.
16회차
약수의 합
JAVA약수나머지 연산 %√n 탐색
한 줄 요약n 자신을 먼저 더해 두면 나머지 약수는 n/2 이하에만 있다. 약수를 짝(d와 n/d)으로 찾으면 √n까지만 봐도 된다.
쉽게 말하면사탕 n개를 똑같이 나눌 때 한 사람 몫이 약수다. 혼자 다 먹는 몫(n)을 먼저 더하면, 둘 이상이 나누는 몫은 절반을 넘지 못하니 n/2까지만 찾는다.
정수 n(0 이상 3000 이하)을 받아 n의 약수를 모두 더한 값을 돌려주는 문제. 예: 12의 약수 1·2·3·4·6·12의 합은 28.
시험 당일에는 노트북 고장으로 응시하지 못했다. 동료 자료로 문제와 회차만 확인해 보충 풀이를 먼저 써 두었고, 이후 직접 풀어 제출해 PASS 했다. 아래에 제출 코드와 보충 풀이를 함께 둔다.
JAVA제출 코드 (PASS)
class Solution {
public int solution(int n) {
if (n == 0) return 0;
int answer = n; // 자기 자신을 먼저 더함
for (int i = 1; i <= n / 2; i++) {
if (n % i == 0) {
answer += i;
}
}
return answer;
}
}
JAVA보충 풀이 (미제출) — 약수를 짝으로 찾는 O(√n)
class Solution {
public int solution(int n) {
int sum = 0;
for (int divisor = 1; divisor <= n / divisor; divisor++) { // ← √n까지만
if (n % divisor != 0) continue;
sum += divisor;
int pair = n / divisor; // ← 짝이 되는 약수
if (pair != divisor) sum += pair; // ← 제곱근은 한 번만
}
return sum;
}
}
💡 풀이 흐름
제출 코드 — n을 먼저 더하고 절반만 돈다.
n 자신을 뺀 약수 중 가장 큰 것은 n/2를 넘을 수 없다 — n이 아닌 약수 d는 짝이 되는 몫 n/d가 1보다 커서 2 이상이므로, d = n ÷ (n/d) ≤ n/2이기 때문이다. 그래서 n을 먼저 더해 두면 나머지는 1 ~ n/2에서만 찾으면 된다. 전수 탐색의 절반이고, 제한(3000) 안에서는 충분하다.
보충 풀이 — 약수는 짝으로 온다.
d가 약수이면 n/d도 약수다. 12라면 (1, 12) · (2, 6) · (3, 4)처럼 짝이 지어지고, 짝의 작은 쪽은 항상 √n 이하에 있다. 그래서 d를 √n까지만 돌며 d와 n/d를 함께 더하면 O(√n)이 된다.
· divisor <= n / divisor — divisor * divisor <= n과 같은 뜻인데, 곱셈을 하지 않아 큰 수에서도 넘침 걱정이 없다.
· pair != divisor — 36처럼 제곱수이면 (6, 6)이 같은 수라 한 번만 더해야 한다.
두 코드 모두 n=0이면 반복이 없으므로 0이다.
(1) 나머지 연산으로 약수 판별n % i == 0이면 i는 n의 약수다. 약수·배수 문제는 거의 이 한 줄에서 시작한다. → ☕ 07. 연산자 총정리(2) 탐색 범위를 줄이는 근거
· 자기 자신을 뺀 약수는 n/2 이하 → 반복 횟수 절반.
· 약수는 짝으로 오고 작은 쪽은 √n 이하 → 반복 횟수 √n.
범위를 줄일 때는 반드시 "왜 그 뒤에는 약수가 없는가"를 설명할 수 있어야 한다.
(3) 짝으로 셀 때의 중복
제곱수에서는 d와 n/d가 같아진다. 이 경우를 따로 막지 않으면 36의 합이 91이 아니라 97(6을 두 번)이 된다.
(4) 제한을 보고 복잡도를 고른다
n이 3000이면 O(n)도 충분하다. 하지만 n이 10억 단위로 커지면 O(n)은 시간 초과가 나고 O(√n)이 필요해진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
n 자신을 뺀 약수가 n/2를 넘을 수 없는 이유는?
n이 아닌 약수 d로 n을 나눈 몫 n/d는 2 이상이기 때문입니다. 그러면 d = n ÷ (n/d)는 n을 2 이상으로 나눈 값이니 n/2 이하일 수밖에 없죠.
보충 풀이에서 pair != divisor 검사를 빼면 36의 결과는?
97이 됩니다. divisor가 6일 때 pair도 6이라 6을 두 번 더하기 때문입니다. 정답 91보다 6이 큽니다. 제곱수가 아닌 수에서는 문제가 드러나지 않아 놓치기 쉽습니다.
반복 조건을 divisor * divisor <= n 대신 divisor <= n / divisor 로 쓴 이유는?
뜻은 같지만 곱셈을 하지 않아 int 범위를 넘칠 일이 없습니다. n이 int 상한 근처라면 divisor * divisor가 넘쳐 음수가 되며 조건이 엉뚱하게 참이 될 수 있습니다. 이 문제(n ≤ 3000)에서는 차이가 없지만 습관으로 들여 두면 안전합니다.
💼 이 유형을 다시 만나면약수 · 소수 판별 · 약수의 개수 문제는 코딩테스트에 계속 나옵니다. 공통 공식은 "√n까지만 보고 짝으로 센다"입니다. 먼저 제한 조건을 보고 O(n)으로 충분한지 판단하고, 크면 바로 √n 방식으로 가세요. 면접에서도 "왜 √n까지만 봐도 되나요?"는 단골 질문이라, 짝 (d, n/d) 중 작은 쪽이 √n 이하라는 설명을 한 문장으로 할 수 있으면 좋습니다.
정리 작업 메모
제출 코드는 Back_end/coding-tests/2026-09-18/SubmittedDivisorSum.java, 보충 풀이는 같은 폴더 Solution.java와 같다(보충 풀이의 // ← 주석만 카드에서 덧붙임).
로컬 검증: 허용 범위 0~3000 전체 3,001개 입력을, 각 수의 배수에 약수를 누적하는 별도 방식으로 만든 기대값과 비교했다. 제출 코드·보충 풀이 둘 다 전 구간 일치 (JDK 21, DivisorSumTest.java). 프로그래머스 채점 화면을 재현한 것은 아니다.
PASS는 본인이 직접 풀어 제출한 결과다. 동료 문서는 문제와 회차를 확인하는 데만 썼다.
16회차
최댓값 구하기
SQLMAX집계 함수DATETIME
한 줄 요약DATETIME도 크기를 비교할 수 있는 값이라, 최댓값이 곧 가장 늦게 들어온 시각이다. GROUP BY 없이 MAX를 쓰면 테이블 전체가 한 그룹이 되어 한 행이 나온다.
쉽게 말하면출입 기록부에서 가장 늦은 시각을 찾으면 마지막에 들어온 동물이다. 시각도 숫자처럼 MAX로 가장 큰 값을 고른다.
동물 보호소 테이블 ANIMAL_INS에서 가장 최근에 들어온 동물이 언제 들어왔는지, 즉 보호 시작일 DATETIME의 최댓값 하나를 시간이라는 이름으로 조회하는 문제.
시험 당일 미응시, 이후 직접 풀어 제출해 PASS. 동료 자료는 문제와 회차를 확인하는 데만 썼고, 그 문서의 채점 결과를 옮겨 적지 않았다.
SQL제출 코드 (PASS)
SELECT MAX(DATETIME) AS 시간
FROM ANIMAL_INS;
💡 풀이 흐름
"가장 최근" = "가장 큰 시각"이다.
1. 날짜도 비교할 수 있는 값 — DATETIME은 과거가 작고 최근이 크다. 그래서 숫자처럼 MAX로 가장 큰 값을 고르면 그것이 가장 늦게 들어온 시각이다.
2. GROUP BY 없이 집계 — 묶는 기준이 없으면 테이블 전체가 한 그룹이 되어 결과는 한 행이다. 같은 최댓값을 가진 행이 여러 개여도 결과는 한 행이다.
3. 별칭 — AS 시간으로 결과 열 이름을 문제가 요구한 대로 맞춘다.
가장 최근 시각 하나를 구하는 문제이므로 동물 이름이나 ID는 선택하지 않는다. GROUP BY를 추가하면 그룹마다 결과가 나와 요구사항이 달라진다.
[다른 방법 — 정렬 후 하나만]
SELECT DATETIME AS 시간
FROM ANIMAL_INS
ORDER BY DATETIME DESC
LIMIT 1;
결과는 같다. 다만 이쪽은 "가장 최근 행을 하나 꺼낸다"는 뜻이라, 시각만 필요할 때는 MAX가 의도를 더 바로 드러낸다. LIMIT는 MySQL·MariaDB 문법이다.
📌 주요 개념 정리
(1) 집계 함수는 여러 행을 값 하나로 줄인다COUNT·SUM·AVG·MAX·MIN은 GROUP BY가 없으면 전체를 한 그룹으로 보고 한 행을 낸다. → 🗄️ 17. 집계 함수와 NULL(2) 빈 집합의 MAX는 NULL
행이 하나도 없으면 MAX는 NULL 한 행을 돌려준다(0행이 아니다). COUNT(*)는 같은 상황에서 0이다.
(3) 집계값과 일반 컬럼을 섞지 않는다SELECT NAME, MAX(DATETIME)처럼 쓰면 NAME이 어느 행의 값인지 불명확하다. DB 설정(ONLY_FULL_GROUP_BY)에 따라 에러가 나거나, 최댓값과 상관없는 행의 이름이 섞여 나올 수 있다.
(4) 최댓값을 가진 행 전체가 필요하면 서브쿼리WHERE DATETIME = (SELECT MAX(DATETIME) FROM ANIMAL_INS)처럼 최댓값을 먼저 구하고 그 값과 같은 행을 고른다. → 🗄️ 12. 서브쿼리 (1) 단일행
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
가장 최근에 들어온 동물의 이름까지 보고 싶다면 어떻게 쓰나?
SELECT NAME, MAX(DATETIME)은 이름이 최댓값의 행에서 온다는 보장이 없습니다. 최댓값을 서브쿼리로 먼저 구하고, WHERE DATETIME = (SELECT MAX(DATETIME) FROM ANIMAL_INS)로 그 행을 고르면 됩니다. 같은 시각에 들어온 동물이 여럿이면 여러 행이 나옵니다.
테이블이 비어 있으면 결과는 0행일까, 1행일까?
NULL이 든 1행입니다. GROUP BY가 없는 집계는 항상 "전체 그룹 하나"의 결과를 내기 때문입니다. 반면 ORDER BY … LIMIT 1 방식은 꺼낼 행이 없으니 0행이 나옵니다. 두 방법이 갈리는 지점이죠.
💼 이 유형을 다시 만나면"가장 최근 · 가장 처음 · 가장 비싼"은 모두 MAX/MIN 문제입니다. 값 하나만 필요하면 집계 함수, 그 행의 다른 정보까지 필요하면 서브쿼리나 ORDER BY … LIMIT로 갑니다. 실무에서도 "마지막 로그인 시각", "가장 최근 주문일" 같은 조회가 매일 나오고, 그룹별로 최신 행을 뽑아야 할 때는 순위 함수(ROW_NUMBER)를 씁니다. → 🗄️ 29. 순위 함수
정리 작업 메모
제출 코드는 Back_end/coding-tests/2026-09-18/max-datetime.sql과 같은 쿼리를 대문자 표기로 정리한 것이다.
로컬 검증: MariaDB 임시 테이블로 날짜 순서 섞임·동일 최댓값·단일 행·빈 집합을 확인했다(verify-max-datetime.sql, 2026-09-22 재실행). 기존 수업 데이터 변경 없음. 프로그래머스 채점 화면을 재현한 것은 아니다.
"빈 테이블이면 NULL 한 행"은 회차 노트의 검증 항목에 근거한다. LIMIT 1 방식의 0행은 표준 동작이며 별도로 실행해 보지는 않았다.
17회차
자릿수 더하기
JAVA나머지 연산 %정수 나눗셈while
한 줄 요약10으로 나눈 나머지가 지금의 1의 자리, 10으로 나누면 그 자리를 버린다. 0이 될 때까지 반복한다.
쉽게 말하면숫자 끝에서 한 자리씩 떼어 저금통에 넣는다. %10으로 떼고 /10으로 그 자리를 버리기를 0이 될 때까지 반복한다.
1억 이하의 자연수 N을 받아 N의 각 자리 숫자를 모두 더한 값을 돌려주는 문제. 예: 123 → 1+2+3 = 6, 987 → 24.
JAVA제출 코드
public class Solution {
public int solution(int n) {
int answer = 0;
while (n > 0) {
answer += n % 10;
n /= 10;
}
return answer;
}
}
💡 풀이 흐름
정수 나눗셈이 소수점을 버린다는 성질을 그대로 쓴 풀이라 문자열로 바꿀 필요가 없다.
1. 맨 뒷자리 꺼내기 — 1234 % 10 = 4. 10으로 나눈 나머지는 언제나 1의 자리다.
2. 그 자리 버리기 — 1234 / 10 = 123. 정수끼리 나누면 소수점 아래가 버려지므로 1의 자리가 사라지고, 십의 자리가 새 1의 자리가 된다.
3. 0이 되면 멈추기 — 더 꺼낼 자리가 없다. 문제에서 n은 1 이상이라 while (n > 0)으로 충분하다.
의식해 둘 점 — 이 코드는 매개변수 n을 직접 깎아 쓰고 있다. 여기서는 뒤에서 n을 다시 쓰지 않아 괜찮지만, 원본 값이 또 필요한 코드였다면 따로 담아 두어야 한다.
추적n = 123일 때 반복마다 값이 어떻게 바뀌나 먼저 예측 → 펼쳐서 확인
시작 n = 123 answer = 0
1회 n % 10 = 3 → answer = 3 n /= 10 → n = 12
2회 n % 10 = 2 → answer = 5 n /= 10 → n = 1
3회 n % 10 = 1 → answer = 6 n /= 10 → n = 0
n > 0 이 거짓 → 반복 종료, 6 반환
숫자는 뒤에서부터 꺼내집니다(3 → 2 → 1). 더하기는 순서와 상관없으니 이 문제에서는 문제가 되지 않지만, 자릿수를 순서대로 써야 하는 문제라면 꺼낸 순서가 거꾸로라는 점을 기억해야 합니다.
📌 주요 개념 정리
(1) % 10과 / 10은 한 쌍이다% 10은 1의 자리를 꺼내고, / 10은 1의 자리를 버린다. 이 둘을 반복하면 뒤에서부터 한 자리씩 다룰 수 있다. → ☕ 07. 연산자 총정리(2) 정수 나눗셈은 소수점을 버린다int끼리 나누면 결과도 int라 7 / 2는 3이다. 이 성질 덕분에 / 10이 "끝자리 지우기"가 된다. → ☕ 06. 형 변환과 타입 변환(3) 복합 대입 연산자n /= 10은 n = n / 10이다. 계산한 결과를 다시 n에 넣어야 반복이 진행된다.
(4) 진법이 바뀌어도 같은 틀
2진수의 자릿수를 다루려면 % 2, / 2로 10만 바꾸면 된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
n이 0이면 while이 몇 번 도는가?
0번입니다. 조건이 처음부터 거짓이라 answer가 0으로 그대로 반환됩니다. 이 문제는 n이 1 이상이라 해당 사항이 없지만, 0을 허용하는 문제였다면 "자릿수 합 0"이 맞는 답인지 먼저 따져야 합니다.
n /= 10 을 n / 10 으로 쓰면 어떻게 되나?
무한 루프입니다. n / 10은 계산만 하고 결과를 어디에도 대입하지 않습니다. n이 줄지 않으니 n > 0이 영원히 참이죠. 같은 실수가 s.replace(...)에서도 나옵니다 — 반환값을 다시 대입해야 반영됩니다.
💼 이 유형을 다시 만나면"각 자리 숫자를 다룬다"는 신호가 보이면 % 10과 / 10 한 쌍을 먼저 떠올리면 됩니다. 진법이 바뀌면 10 대신 그 진법을 쓰면 되고요(% 2, / 2). 문자열로 바꿔 charAt(i) - '0'으로 푸는 방법도 있는데, 자릿수를 거꾸로 만들거나 자리별로 조작해야 하면 나눗셈 쪽이, 자리 순서를 그대로 보면서 처리해야 하면 문자열 쪽이 편합니다. 같은 틀이 자연수 뒤집어 배열로 만들기에서도 쓰였습니다.
정리 작업 메모
제출 코드는 17회차 DOCX 원문에서 그대로 옮겼다(Back_end/coding-tests/2026-09-21/DigitSum.java는 같은 코드를 클래스 이름만 바꿔 보존). 원래 배지는 "PASS · 제출 기록"이었다.
로컬 검증: 1~1,000,000 전수 + 큰 값 몇 개(9,999,999 · 10,000,000 등) 통과. 문제 상한인 1억 자체는 검증 입력에 들어 있지 않다(CodingTest17Test.java를 읽고 확인). 기댓값은 Integer.toString(n)의 각 문자를 더하는 다른 방법으로 생성했다(JDK 21, CodingTest17Test.java). 프로그래머스 채점 화면은 아니다.
"추적" 블록의 값은 코드로 결정되는 계산 과정을 손으로 따라간 것이다.
17회차
두 개 뽑아서 더하기
JAVA배열 기본값Arrays.copyOfTreeSet
한 줄 요약접근은 맞았다. 고정 크기 배열을 중복 제거 장치로 쓰려다 크기 · 빈칸 · 유효 길이 세 곳에서 어긋났다.
쉽게 말하면0으로 미리 채워진 달걀판에서 빈칸과 진짜 0을 구별하지 못하면 정답 0이 사라진다. 판은 201칸 이상 넉넉히 잡고, 실제로 채운 칸까지만 잘라 써야 한다.
배열에서 서로 다른 두 수를 뽑아 더해 만들 수 있는 모든 값을 중복 없이 오름차순으로 돌려준다. 원소는 0 이상 100 이하, 길이는 2 이상 100 이하다.
JAVA제출 코드
public boolean same(int num, int[] answer){
for(int i=0;i<answer.length;i++){ // ← 빈칸까지 훑는다
if(num == answer[i]){ return false; }
}
return true;
}
public int[] solution(int[] numbers) {
int[] temp = new int[100]; // ← 최대 201가지를 담을 수 없다
int count = 0;
for(int i=0;i<numbers.length-1;i++){
for(int j=i+1;j<numbers.length;j++){
int num = numbers[i]+numbers[j];
if(same(num,temp)==true){
temp[count] = numbers[i]+numbers[j];
count++;
}else{ continue; }
}
}
Arrays.sort(temp); // ← 빈칸 0이 전부 앞으로 몰린다
int[] answer = new int[count];
int count2=0;
for(int i=0; i<temp.length; i++){
if(temp[i]!=0){ // ← 정답인 0까지 버린다
answer[count2]=temp[i];
count2++;
}
}
return answer;
}
💡 정답 풀이 — 세 곳을 고친다
① 배열 크기가 모자랐다 (런타임 에러). 원소가 0~100이므로 두 수의 합은 0~200, 즉 최대 201가지다. new int[100]은 이걸 담지 못한다. 넉넉하게 new int[300]으로 잡는다.
② 중복 검사가 빈칸까지 훑었다 (합이 0이면 사라진다).int 배열은 생성과 동시에 전부 0으로 채워진다. 제출한 same은 answer.length 전체를 돌기 때문에, 합이 0일 때 아직 쓰지 않은 뒤쪽 칸의 0과 마주쳐 "이미 있는 값"으로 착각하고 건너뛴다. 실제로 채운 개수인 count까지만 검사하도록 범위를 좁힌다.
③ 0을 빈칸으로 오해하고 지웠다. 배열 전체를 Arrays.sort하면 남아 있던 빈칸 0들이 전부 앞으로 몰린다. 그 뒤 "0이 아닌 값만 골라 담기"로 빈칸을 걸러 내려 했는데, 이 방식은 정답인 0까지 같이 버린다. Arrays.copyOf(temp, count)로 유효 구간만 잘라낸 뒤 정렬하면 빈칸이 애초에 섞이지 않는다.
JAVA정정본 — 세 가지를 반영
public boolean same(int num, int[] arr, int count){
for(int i = 0; i < count; i++){ // 채운 데까지만 본다
if(num == arr[i]){ return false; }
}
return true;
}
public int[] solution(int[] numbers) {
int[] temp = new int[300]; // 201가지를 넉넉히 담는다
int count = 0;
for(int i = 0; i < numbers.length - 1; i++){
for(int j = i + 1; j < numbers.length; j++){
int num = numbers[i] + numbers[j];
if(same(num, temp, count)){
temp[count] = num;
count++;
}
}
}
int[] answer = Arrays.copyOf(temp, count); // 유효 구간만 잘라내고
Arrays.sort(answer); // 그것만 정렬한다
return answer;
}
JAVA같은 요구사항을 컬렉션으로
TreeSet<Integer> sums = new TreeSet<>();
for (int i = 0; i < numbers.length - 1; i++)
for (int j = i + 1; j < numbers.length; j++)
sums.add(numbers[i] + numbers[j]);
return sums.stream().mapToInt(Integer::intValue).toArray();
중복 제거 + 오름차순 정렬은 TreeSet의 기본 동작이라 위 세 함정이 처음부터 생기지 않습니다. 배열 풀이를 알아 두는 것은 "자료구조가 대신해 주던 일이 무엇이었나"를 알기 위해서예요 — 크기 산정·중복 검사·유효 길이 관리가 전부 직접 해야 할 일이었다는 것이 위 세 실수로 드러납니다. → 🛠️ 03. Set 계열 — HashSet vs LinkedHashSet vs TreeSet
실행 결과제출본의 실패를 실제로 재현했다 먼저 예측 → 펼쳐서 확인
--- ② 합이 0인 값이 사라지는가 ---
[0,0,1] 기대 [0, 1] / 제출본 [1]
--- ① 고유한 합이 100개를 넘으면 ---
0~99 (길이 100) 고유한 합 197개
제출본 → ArrayIndexOutOfBoundsException: Index 100 out of bounds for length 100
①은 바로 터지는 실패라 오히려 찾기 쉽습니다. 무서운 쪽은 ②예요 — 에러 없이 답에서 0 하나만 빠집니다. 입력에 0이 두 개 이상 없으면 재현조차 안 되니, 테스트 케이스를 잘못 고르면 "왜 틀렸는지 모르겠다"로 끝납니다.
📌 주요 개념 정리
(1) int[]의 기본값은 0
"아직 안 씀"과 "값이 0"을 구분하지 못한다. 그래서 count를 따로 들고 다녀야 한다. → ☕ 16. 배열(2) 고정 크기 배열은 최대 개수를 먼저 계산한다
여기서는 합의 범위 0~200 = 201가지.
(3) 오름차순 정렬은 0을 맨 앞으로 보낸다
빈칸이 섞인 배열 전체를 정렬하면 유효 데이터와 뒤섞인다. Arrays.copyOf(원본, 길이)는 0번부터 지정 길이만큼 잘라 새 배열을 만드니, 정렬은 잘라낸 뒤에 한다.
(4) 중복 제거는 HashSet, 중복 제거 + 정렬은 TreeSet
이 문제는 후자에 정확히 해당한다. Set<Integer>를 int[]로 바꿀 때는 stream().mapToInt(Integer::intValue).toArray()를 쓴다. → 🛠️ 08. Stream API
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
numbers = [0, 0, 1] 일 때 제출본은 왜 0을 빠뜨리나?
합 0을 넣으려는 순간 same(0, temp)가 temp 전체를 훑습니다. temp는 아직 거의 비어 있고 그 빈칸은 전부 0이죠. 그래서 "0은 이미 있다"고 판단해 건너뜁니다. 검사 범위를 count까지로 좁히면 아직 아무것도 안 넣었으니 0을 정상적으로 추가합니다.
temp를 넉넉히 500으로만 키우면 이 문제가 다 풀릴까?
아니요. 크기는 ①만 해결합니다. ②(빈칸을 값으로 오인)와 ③(정답 0을 버림)은 그대로예요. 오히려 배열이 클수록 빈칸이 많아져 ②가 더 잘 터집니다. 세 가지는 원인이 각각 다른 버그입니다.
왜 정렬을 copyOf 뒤에 하나? 순서를 바꾸면?
먼저 정렬하면 빈칸 0들이 앞으로 몰려 유효 데이터가 뒤로 밀립니다. 그 상태에서 앞에서 count개를 잘라내면 빈칸만 가져오게 되죠. 잘라내기 → 정렬 순서여야 유효 구간이 보존됩니다.
💼 이 유형을 다시 만나면코딩테스트에서 "중복 없이" + "오름차순"이라는 두 단어가 같이 나오면 TreeSet을 반사적으로 떠올리면 됩니다. 배열로 직접 관리하겠다고 결정했다면 ①담을 최대 개수 ②유효 길이 변수 ③잘라내기 시점 세 가지를 시작 전에 정해 두세요. 실무에서도 같은 함정이 나옵니다 — 0·빈 문자열·null을 "값이 없음"의 표시로 재사용하면 진짜 0과 구분할 수 없게 됩니다. 그래서 별도의 길이 필드나 Optional, DB라면 NULL을 따로 두는 것이죠.
정리 작업 메모
제출 코드는 17회차 DOCX 원문에서 옮겼고 // ← 주석만 덧붙였다(원본은 Back_end/coding-tests/2026-09-21/TwoSumPickSubmitted.java). 정정본은 TwoSumPickFixed.java, 컬렉션 풀이는 TwoSumPickTreeSet.java와 같은 로직이다.
로컬 검증(JDK 21, CodingTest17Test.java): 정정본·TreeSet본 모두 공식 예제 2건 / 원소 0~3·길이 2~5 조합 전수 1,360건 / 제한 범위 안 무작위 5,000건 / 전부 0 · 전부 100 · 길이 상한 경계를 통과했다.
기댓값은 TreeSet도 배열 누적도 아닌 boolean[201] 존재표로 따로 만들었다. 검증 대상과 검증 방식이 같은 원리를 쓰면, 같은 착각을 양쪽이 공유해 "통과"가 나올 수 있기 때문이다.
로컬 검증이며 프로그래머스 채점 기록이 아니다.
17회차
이름이 없는 동물의 아이디
SQLIS NULLNULL 비교빈 문자열
한 줄 요약NULL은 값이 아니라 "값이 없음"이라 =로 비교할 수 없다. IS NULL만 통한다.
쉽게 말하면이름표가 아예 없는 것과 빈 이름표(빈 문자열)는 다르다. 없는 것은 = 로 물어볼 수 없고 IS NULL로만 찾는다.
동물 보호소 테이블 ANIMAL_INS에서 들어올 당시 이름이 없었던(NAME이 NULL인) 동물의 ID를 아이디 오름차순으로 조회하는 문제.
SQL제출 코드
SELECT ANIMAL_ID
FROM ANIMAL_INS
WHERE NAME IS NULL
ORDER BY ANIMAL_ID ASC;
💡 풀이 흐름
"이름이 없다" = NAME이 NULL이다 → IS NULL로 고른다.
1. 행 고르기 — WHERE NAME IS NULL. NULL 여부는 반드시 IS NULL · IS NOT NULL로 검사한다.
2. 정렬 — ORDER BY ANIMAL_ID ASC로 아이디 순.
왜 = NULL이 안 되나 — NULL은 어떤 값과도 같다고도 다르다고도 판정되지 않는다. 그래서 NAME = NULL은 에러가 아니라 0건이 나온다. 조용히 빈 결과가 나오는 쪽이라 오히려 찾기 어렵다.
빈 문자열 ''은 NULL이 아니다 — 이름 칸을 비워 저장한 데이터와 아예 이름이 없는 데이터를 같이 묶고 싶다면 조건을 따로 써 줘야 한다.
실행 결과제출한 쿼리를 임시 테이블 픽스처에 그대로 돌렸다 (MariaDB 12.3) 먼저 예측 → 펼쳐서 확인
빈 문자열을 넣어 둔 A400 이 결과에 없습니다.'' 는 "길이가 0인 값"이지 "값이 없음"이 아니기 때문이죠. 같은 스크립트의 항목별 확인에서 NAME = NULL 이 에러 없이 0건이라는 것도 함께 확인했습니다.
📌 주요 개념 정리
(1) NULL과의 비교는 UNKNOWN
SQL의 조건은 참·거짓 외에 UNKNOWN(알 수 없음)이 있다. NAME = NULL, NAME != NULL은 모두 UNKNOWN이 되고, WHERE는 참인 행만 통과시키므로 결과는 0건이다.
(2) NULL 검사는 IS NULL / IS NOT NULL
NULL인지 묻는 전용 연산자다. 이 둘만 참·거짓을 제대로 돌려준다. → 🗄️ 14. NULL 함정(3) NULL과 빈 문자열은 다르다''은 길이가 0인 문자열 값이고, NULL은 값이 없음이다. 둘 다 찾으려면 NAME IS NULL OR NAME = ''.
(4) NULL을 다른 값으로 바꾸려면IFNULL(NAME, 'No name')이나 COALESCE로 NULL을 원하는 값으로 채울 수 있다. → 🗄️ 17. 집계 함수와 NULL
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
WHERE NAME = NULL 은 에러인가, 0건인가?
0건입니다. 문법은 맞아서 에러가 나지 않고, NULL과의 비교 결과가 참도 거짓도 아닌 UNKNOWN이라 어떤 행도 통과하지 못합니다. 같은 이유로 NAME != NULL도 0건입니다.
이름 칸에 빈 문자열이 저장된 행은 이 쿼리에 걸릴까?
아니요. ''는 길이가 0인 값이지 "값이 없음"이 아닙니다. 둘을 함께 찾으려면 WHERE NAME IS NULL OR NAME = ''처럼 조건을 따로 적어야 합니다.
💼 이 유형을 다시 만나면조건에 "~이 없는" · "입력되지 않은"이 보이면 먼저 IS NULL을 떠올리세요. 다음 회차의 이름이 있는 동물의 아이디는 정확히 이 문제를 뒤집은 IS NOT NULL입니다. 실무에서는 "미입력 데이터 찾기"(연락처가 비어 있는 회원, 처리일이 없는 주문 등)가 같은 모양인데, 화면에서 빈칸으로 저장되면 '', 아예 안 들어오면 NULL이 되는 식으로 두 가지가 섞여 있는 경우가 많아 둘 다 확인하는 습관이 필요합니다.
정리 작업 메모
제출 코드는 17회차 DOCX 원문에서 옮겼다(Back_end/coding-tests/2026-09-21/nameless-animal-ids.sql). 원래 배지는 "PASS · 제출 기록"이었다.
실행 결과는 verify-shelter-queries.sql로 세션 임시 테이블 픽스처를 만들어 MariaDB 12.3에서 실행한 것이다. TEMPORARY 테이블만 사용해 수업 데이터·문제 원본은 건드리지 않았다.
같은 스크립트의 항목별 확인 ①~③(IS NULL이 2건만 고른다 · = NULL은 0건 · 빈 문자열은 IS NULL에 걸리지 않는다)이 모두 passed = 1이었다.
17회차
있었는데요 없었습니다
SQLINNER JOINDATETIME 비교테이블 별칭
한 줄 요약두 테이블에 모두 있는 개체만 비교하면 되므로 INNER JOIN이면 충분하다. 날짜는 과거가 작은 값이다.
쉽게 말하면입소 기록과 입양 기록이 둘 다 있는 동물만 맞대 보면 된다. 입양일이 입소일보다 앞서면 시간이 거꾸로 간 잘못된 기록이다.
보호 시작 기록 ANIMAL_INS와 입양 기록 ANIMAL_OUTS를 맞대어, 관리자 실수로 입양일이 보호 시작일보다 앞서게 잘못 입력된 동물의 ID와 이름을 보호 시작일이 빠른 순으로 조회하는 문제.
SQL제출 코드
SELECT I.ANIMAL_ID, I.NAME
FROM ANIMAL_INS I JOIN ANIMAL_OUTS O ON I.ANIMAL_ID = O.ANIMAL_ID
WHERE O.DATETIME < I.DATETIME
ORDER BY I.DATETIME ASC;
💡 풀이 흐름
두 시각을 같은 행에 놓고(JOIN) → 비교하고(WHERE) → 정렬한다(ORDER BY).
1. INNER JOIN — 보호 기록과 입양 기록이 둘 다 있는 개체만 비교 대상이다. 한쪽에만 있는 행은 애초에 비교할 짝이 없으므로 JOIN(= INNER JOIN)이 맞다. LEFT JOIN을 써도 짝 없는 행의 NULL이 WHERE에서 어차피 탈락해 결과는 같지만, 불필요한 행을 만들었다가 버리는 셈이다.
2. 날짜 비교 — DATETIME은 과거가 작은 값이다. "입양일이 보호 시작일보다 앞선다"가 곧 O.DATETIME < I.DATETIME이다. 날짜를 문자열처럼 따로 다룰 필요 없이 비교 연산자를 그대로 쓴다.
3. 정렬 — "보호 시작일이 빠른 순"이므로 I.DATETIME 오름차순이다. ASC는 기본값이라 생략해도 되지만, 의도를 드러내려고 적었다.
별칭(alias)이 꼭 필요한 이유 — 두 테이블에 ANIMAL_ID·DATETIME이라는 같은 이름의 컬럼이 있다. I·O로 짧은 별칭을 주지 않으면 어느 쪽 컬럼인지 매번 테이블 이름을 다 써야 하고, 빠뜨리면 모호한 컬럼 에러가 난다.
실행 결과제출한 쿼리를 임시 테이블 픽스처에 그대로 돌렸다 (MariaDB 12.3) 먼저 예측 → 펼쳐서 확인
같은 픽스처에서 함께 확인한 것: 입양 기록이 없는 A100·A300은 INNER JOIN 단계에서 빠지고, LEFT JOIN으로 바꿔도 결과가 같았습니다. 또 정렬 기준을 O.DATETIME(입양일)으로 바꾸면 A500이 먼저 나와 순서가 달라집니다 — 같은 이름의 두 컬럼 중 어느 쪽으로 정렬하느냐가 결과를 바꾼다는 뜻이죠.
📌 주요 개념 정리
(1) INNER JOIN은 양쪽에 짝이 있는 행만 남긴다ON I.ANIMAL_ID = O.ANIMAL_ID로 짝을 지어, 짝을 찾은 행만 결과에 남는다. → 🗄️ 24. JOIN(2) 짝이 없는 행까지 살리려면 OUTER JOIN
"입양 기록이 없는 동물"처럼 없는 쪽을 찾는 문제라면 LEFT JOIN + IS NULL을 쓴다. 이 문제는 둘 다 있어야 비교가 되므로 INNER JOIN이다. → 🗄️ 33. OUTER JOIN(3) 날짜·시각도 크기 비교가 된다<·>·BETWEEN을 숫자처럼 쓴다. 과거일수록 작다.
(4) 같은 이름의 컬럼은 별칭으로 구분한다FROM ANIMAL_INS I처럼 테이블 뒤에 별칭을 붙이고, SELECT·WHERE·ORDER BY에서 I.·O.를 빠짐없이 붙인다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
ANIMAL_OUTS에만 있고 ANIMAL_INS에는 없는 동물은 결과에 나올까?
아니요. INNER JOIN은 양쪽 모두에서 짝을 찾은 행만 남깁니다. 그런 "들어온 기록 없이 나간 기록"을 찾는 것이 목적이라면 그때는 LEFT JOIN + IS NULL을 써야 합니다 — 다른 문제죠.
ORDER BY를 O.DATETIME으로 바꾸면 무엇이 달라지나?
입양일 순으로 정렬됩니다. 문제가 요구한 것은 "보호 시작일이 빠른 순"이라 I.DATETIME이 맞습니다. 두 컬럼 이름이 똑같아서 별칭을 빼먹으면 어느 쪽인지 알 수 없게 되는, 실수하기 좋은 자리입니다.
💼 이 유형을 다시 만나면"데이터가 이상한 것을 찾아라" 유형입니다 — 종료일이 시작일보다 빠르다, 수정 시각이 생성 시각보다 이르다 같은 순서가 뒤집힌 기록을 잡는 쿼리는 실무에서 자주 씁니다. 데이터 정합성 점검 배치가 대개 이 모양이에요. 포인트는 두 시각을 같은 행에 놓는 것이고, 그래서 거의 항상 JOIN이 먼저 나옵니다.
정리 작업 메모
제출 코드는 17회차 DOCX 원문에서 옮겼다(Back_end/coding-tests/2026-09-21/wrong-adoption-order.sql). 원래 배지는 "PASS · 제출 기록"이었다.
실행 결과는 verify-shelter-queries.sql(MariaDB 12.3)의 출력이다. 항목별 확인 ④ INNER JOIN이 짝 없는 A100·A300을 버린다 · ⑤ LEFT JOIN으로 바꿔도 결과가 같다 · ⑥ 뒤집힌 기록 A200,A500만 골라낸다 · ⑦ I.DATETIME 오름차순이면 A200이 먼저다 · ⑧ O.DATETIME으로 정렬하면 A500이 먼저다 — 모두 passed = 1.
⑧은 처음에 실패했다(passed = 0). 픽스처의 입양일 순서가 보호 시작일 순서와 우연히 같아서, "별칭을 빼먹으면 순서가 달라진다"는 주장을 보여 주지 못하는 검사였다. 값을 잡아 두 정렬 결과가 실제로 갈라지게 고쳤다. 검사가 통과했다는 것과 검사가 의미 있다는 것은 다르다 — 한 번은 일부러 틀리게 만들어 빨간불이 켜지는지 확인해 보는 편이 안전하다.
TEMPORARY 테이블만 사용 — 같은 이름의 실제 테이블이 있어도 가려지기만 하고 바뀌지 않으며 세션이 끝나면 사라진다. 수업 데이터·문제 원본은 건드리지 않았다.
18회차
정수 내림차순으로 배치하기
JAVAArrays.sortlongStringBuilder
한 줄 요약자릿수를 오름차순으로 정렬한 뒤 거꾸로 읽으면 큰 숫자부터 배치된다. 입력이 최대 80억이라 long과 Long.parseLong을 쓴다.
쉽게 말하면118372를 한 자리씩 나눠 1·1·2·3·7·8로 정렬하고 뒤에서 읽어 873211을 만든다.
정수 n(1 이상 8,000,000,000 이하)의 각 자릿수를 큰 것부터 작은 순으로 다시 배치한 새 정수를 돌려주는 문제. 예: 118372 → 873211.
JAVA오답노트의 정답 풀이 · 문자열 방식
import java.util.Arrays;
// 오답노트의 정답 풀이를 실행 가능한 클래스로 정리. 제출 코드는 원문에 없음.
// ※ 프로그래머스에 제출할 때는 클래스 이름을 class Solution 으로 바꿔야 한다(채점기가 Solution을 찾는다).
public class DescendingDigitsString {
public long solution(long n) {
String[] arr = String.valueOf(n).split(""); // ← 한 글자씩 나눈다
Arrays.sort(arr); // ← 오름차순 정렬
StringBuilder sb = new StringBuilder();
for (int i = arr.length - 1; i >= 0; i--) { // ← 뒤에서부터 읽어 내림차순
sb.append(arr[i]);
}
return Long.parseLong(sb.toString()); // ← int가 아니라 long
}
}
💡 정답 풀이
제출 코드가 원문에 없다. FAIL 결과와 정답 풀이만 남아 있어, 실제로 무엇 때문에 틀렸는지는 확인할 수 없다. 위 코드는 복습용 정답 풀이이며 실제 제출본의 오류 원인을 재현한 것이 아니다. 아래는 오답노트가 짚은 복습 포인트다.
풀이 흐름
1. 자릿수로 쪼개기 — String.valueOf(n)으로 문자열을 만들고 split("")으로 한 글자씩 나눈다.
2. 정렬 — Arrays.sort는 오름차순만 해 준다. 한 자리 숫자 문자열은 사전순과 숫자순이 같아 그대로 정렬해도 된다.
3. 거꾸로 읽기 — length - 1에서 0까지 읽으며 StringBuilder에 붙이면 내림차순이 된다.
4. 다시 숫자로 — 입력이 최대 80억이라 Integer.parseInt로는 담을 수 없다. Long.parseLong을 쓴다.
또 다른 풀이 — 수학적 분리% 10으로 맨 끝 자리를 꺼내고 / 10으로 버리기를 반복해 숫자 배열을 만든 다음 정렬한다(자릿수 더하기와 같은 틀). 아래 코드가 그 방식이다.
JAVA오답노트의 두 번째 정답 풀이 · 수학적 분리 방식
import java.util.Arrays;
// 오답노트의 두 번째 정답 풀이: 나머지와 정수 나눗셈으로 자릿수 분리.
public class DescendingDigitsArithmetic {
public long solution(long n) {
long[] arr = new long[String.valueOf(n).length()];
long num = n;
int count = 0;
while (num > 0) {
arr[count++] = num % 10; // ← 끝자리 꺼내기
num /= 10; // ← 끝자리 버리기
}
Arrays.sort(arr);
String ans = "";
for (int i = arr.length - 1; i >= 0; i--) {
ans = ans + arr[i];
}
return Long.parseLong(ans);
}
}
📌 주요 개념 정리
(1) 숫자에는 길이가 없다
숫자 자체에는 길이 속성이 없다. 배열은 arr.length(필드), 문자열은 str.length()(메서드)다. 자릿수를 알려면 문자열로 바꾸거나 10으로 나눠 센다. → ☕ 13. String 주요 메서드(2) 문제의 범위가 자료형을 정한다int는 약 21억까지다. 입력이 80억까지이므로 매개변수·반환형·파싱 모두 long이어야 한다. → ☕ 04. 기본타입 8가지(3) 내림차순 = 오름차순 정렬 + 거꾸로 읽기
기본형 배열(int[], long[])의 Arrays.sort에는 내림차순 옵션이 없다. 오름차순으로 정렬한 뒤 뒤에서부터 읽는 것이 가장 간단하다.
(4) 문자열을 반복해서 조립할 때는 StringBuilderans = ans + …는 매번 새 문자열을 만든다. 반복해서 붙일 때는 StringBuilder로 누적한다. → ☕ 15. StringBuffer · StringBuilder
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
7999999999를 정렬하면 입력 상한인 80억보다 커져도 괜찮을까?
결과는 9999999997입니다. 입력의 상한이 결과의 상한인 것은 아니죠. long 범위에는 들어가며, int로 바꾸면 범위를 벗어납니다.
정렬 후 for문을 0부터 앞으로 돌리면 결과는?
오름차순 숫자가 나옵니다. 118372라면 112378이죠. Arrays.sort는 작은 것부터 늘어놓으므로, 큰 것부터 쓰려면 마지막 칸(length - 1)부터 거꾸로 읽어야 합니다.
마지막 줄을 Integer.parseInt로 쓰면 어떤 입력에서 문제가 되나?
결과가 약 21억(int 최댓값)을 넘는 입력에서 NumberFormatException이 납니다. 예를 들어 80억대 입력은 결과도 열 자리라 int에 담기지 않습니다.
💼 이 유형을 다시 만나면"숫자를 자릿수 단위로 재배치"하는 문제는 문자열로 바꿔 정렬이 가장 짧고, % 10 · / 10 분리는 자릿수를 숫자로 다뤄야 할 때 씁니다. 어느 쪽이든 시작 전에 제한 조건의 최댓값을 보고 int/long을 먼저 정하는 것이 기본기입니다. 객체 배열(Integer[], String[])이라면 Arrays.sort(arr, Collections.reverseOrder())로 바로 내림차순 정렬도 할 수 있습니다.
정리 작업 메모
원문(18회차 오답노트) 결과는 FAIL이며 제출 코드 칸이 비어 있다. 제출본을 만들어 내거나 특정 실수를 실제 실패 원인이라고 단정하지 않았다.
두 코드는 Back_end/coding-tests/2026-09-23/DescendingDigitsString.java와 DescendingDigitsArithmetic.java에서 가져왔다(// ← 주석만 덧붙임). 원문의 첫 문제 제목은 '정수 내림차순 배치'로 축약되어 있어 공식 문제명을 썼다.
로컬 검증: Java 두 풀이 모두 20,008개 입력 통과 — 1~10,000 전수 · 재현 가능한 무작위 10,000 · 경계 8개(JDK 21, CodingTest18Test.java). 기댓값은 정렬 대신 숫자 빈도표로 만들었다.
제출 당시 FAIL과 로컬 정답 풀이 PASS는 서로 다른 기록이다.
18회차
폰켓몬
JAVAHashSet중복 제거Math.min
한 줄 요약정답은 종류 수와 선택 가능한 마릿수 N/2 중 작은 값이다. 종류 수는 HashSet에 넣고 size()로 센다.
쉽게 말하면종류가 많아도 두 마리만 고를 수 있으면 두 종류가 한계다. 세 마리를 골라도 종류가 두 개뿐이면 두 종류가 한계다.
폰켓몬 N마리(N은 짝수, 최대 10,000)의 종류 번호 배열 nums가 주어질 때, N/2마리를 골라 얻을 수 있는 종류 수의 최댓값을 돌려주는 문제. 예: [3,1,2,3] → 2마리를 고르므로 최대 2종류.
JAVA오답노트의 정답 풀이
import java.util.HashSet;
// 오답노트의 정답 풀이. FAIL 제출본을 복원한 코드가 아님.
// ※ 프로그래머스에 제출할 때는 클래스 이름을 class Solution 으로 바꿔야 한다(채점기가 Solution을 찾는다).
public class PokemonKinds {
public int solution(int[] nums) {
int maxPick = nums.length / 2; // ← 고를 수 있는 마릿수
HashSet<Integer> pocket = new HashSet<>();
for (int num : nums) {
pocket.add(num); // ← 같은 번호는 한 번만 남는다
}
int kindCount = pocket.size(); // ← 종류 수
if (kindCount > maxPick) {
return maxPick;
} else {
return kindCount;
}
}
}
💡 정답 풀이
제출 코드가 원문에 없다. FAIL 결과와 정답 풀이만 남아 있어, 실제로 무엇 때문에 틀렸는지는 확인할 수 없다. 위 코드는 복습용 정답 풀이이며 실제 제출본의 오류 원인을 재현한 것이 아니다.
풀이 흐름
1. 고를 수 있는 마릿수 — nums.length / 2.
2. 종류 수 세기 — HashSet에 번호를 모두 넣으면 중복이 사라지고 size()가 종류 수가 된다. 저장 순서와 인덱스는 제공하지 않지만 이 문제는 개수만 필요하다.
3. 둘 중 작은 값 — 마지막 if문은 Math.min(pocket.size(), nums.length / 2)와 같은 뜻이다.
왜 두 값 중 작은 값으로 충분한가? 답은 종류 수도, N/2도 넘을 수 없다(고른 마릿수보다 종류가 많을 수 없고, 있는 종류보다 많이 고를 수도 없다). 그리고 그 작은 값만큼 서로 다른 종류를 하나씩 고르면 그 상한에 실제로 도달한다. 종류가 부족하면 남는 자리는 이미 고른 종류로 채우면 된다. 그래서 모든 조합을 탐색할 필요가 없다.
📌 주요 개념 정리
(1) 종류 수 = Set의 size()HashSet은 같은 값을 두 번 넣어도 하나만 남긴다. "서로 다른 것이 몇 개인가"는 Set에 넣고 size()를 보면 된다. → 🛠️ 03. Set 계열(2) HashSet은 순서를 보장하지 않는다
인덱스로 꺼낼 수 없고 반복 순서도 넣은 순서와 다를 수 있다. 순서가 필요하면 LinkedHashSet(넣은 순서), TreeSet(정렬 순서)을 쓴다.
(3) 기본형 배열과 Wrapperint 값을 HashSet<Integer>에 넣으면 자동으로 Integer로 바뀐다(오토박싱). 컬렉션에는 객체만 담기기 때문이다. → ☕ 10. final · 상수와 Wrapper 클래스(4) "최대 몇 개"는 상한부터 따진다
조합을 다 만들어 보기 전에 답이 넘을 수 없는 값(여기서는 종류 수와 N/2)을 찾고, 그 값에 도달할 수 있는지 확인하면 계산이 한 줄로 줄어드는 경우가 많다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
[3,3,3,2,2,2]에서 세 마리를 고르는데 정답이 왜 2인가?
마릿수는 3이지만 종류 번호는 3과 2뿐입니다. 이 문제의 반환값은 선택한 마릿수가 아니라 종류 수입니다.
[3,3,3,2,2,4]의 답은? 어떤 값이 상한이 되나?
3입니다. 종류는 3·2·4로 3개, 고를 수 있는 마릿수도 6/2 = 3이라 두 상한이 같습니다. 3, 2, 4를 하나씩 고르면 3종류에 도달합니다.
HashSet 대신 ArrayList에 넣고 size()를 보면 왜 안 되나?
ArrayList는 중복을 그대로 담기 때문입니다. size()가 종류 수가 아니라 전체 마릿수(N)가 됩니다. 중복을 없애는 것이 Set의 역할입니다.
💼 이 유형을 다시 만나면문제에 "서로 다른 · 종류 · 중복 없이"가 보이면 HashSet부터 떠올리세요. 코딩테스트 입문 단계에서 아주 자주 나오는 패턴입니다. 그리고 "최대 몇 개를 고를 수 있나"는 조합을 전부 만들기 전에 상한을 찾아 Math.min으로 끝나는지 먼저 의심해 보세요. 실무에서도 같은 일이 많습니다 — 방문자 ID에서 순 방문자 수를 세는 것은 Java라면 Set, SQL이라면 COUNT(DISTINCT 컬럼)입니다.
정리 작업 메모
원문(18회차 오답노트) 결과는 FAIL이며 제출 코드 칸이 비어 있다. 제출본을 만들어 내거나 특정 실수를 실제 실패 원인이라고 단정하지 않았다.
코드는 Back_end/coding-tests/2026-09-23/PokemonKinds.java와 같다(// ← 주석과 Solution 안내 주석만 덧붙임).
로컬 검증: 공식 예제 3건 · 원소 1~3 · 짝수 길이 2~8인 배열 전수 7,380건 · 길이 10,000 경계 2건 통과(JDK 21, CodingTest18Test.java). 작은 배열의 기댓값은 N/2개를 고르는 모든 조합을 실제로 열거해 계산했고, 입력 배열이 바뀌지 않는지도 검사했다.
18회차
이름이 있는 동물의 아이디
SQLIS NOT NULLNULL 비교빈 문자열
한 줄 요약NULL이 아닌 이름을 가진 행을 IS NOT NULL로 고르고 ID 오름차순으로 조회한다. != NULL로는 한 건도 나오지 않는다.
쉽게 말하면지난 회차의 이름 없는 동물이 IS NULL이었다면 이번에는 조건을 IS NOT NULL로 뒤집는다.
동물 보호소 테이블 ANIMAL_INS에서 이름이 있는(NAME이 NULL이 아닌) 동물의 ID를 아이디 오름차순으로 조회하는 문제.
SQL제출 코드
SELECT ANIMAL_ID
FROM ANIMAL_INS
WHERE NAME IS NOT NULL
ORDER BY ANIMAL_ID ASC;
💡 풀이 흐름
이름이 없는 동물의 아이디의 조건을 뒤집은 문제다.
1. 행 고르기 — WHERE NAME IS NOT NULL. 이름이 들어 있는 행만 남긴다.
2. 정렬 — ORDER BY ANIMAL_ID ASC. 필터링 조건과 결과 정렬은 별개의 역할이다.
왜 NAME != NULL은 안 되나 — NAME != NULL이나 NAME <> NULL은 NULL 여부를 검사하지 못한다. SQL 비교 결과가 UNKNOWN이 되어 WHERE를 통과하지 못하기 때문이다. 이름이 있는 행조차 모두 탈락해 0건이 된다.
빈 문자열은 포함된다 — MariaDB의 기본 문자열 처리에서 ''는 NULL이 아니므로 이 쿼리는 빈 문자열 행도 포함한다. 문제에 없는 조건을 추가해 빈 문자열을 임의로 빼지 않는다.
실행 결과제출 SQL을 임시 테이블 픽스처에 실행 (MariaDB 12.3.3) 먼저 예측 → 펼쳐서 확인
픽스처: A100(NULL), A200(체리), A300(NULL), A400(빈 문자열), A500(보리)
WHERE NAME IS NOT NULL 결과: A200, A400, A500
WHERE NAME <> NULL 결과: 0건
A400(빈 문자열)이 결과에 들어 있습니다.''은 길이가 0인 값이라 "NULL이 아님"에 해당하죠. 그리고 <> NULL은 이름이 있는 A200·A500까지 포함해 아무것도 고르지 못했습니다.
📌 주요 개념 정리
(1) NULL 검사는 IS NULL / IS NOT NULL=·!=·<>로 NULL과 비교하면 결과가 UNKNOWN이라 어떤 행도 통과하지 못한다. → 🗄️ 14. NULL 함정(2) WHERE는 '참'인 행만 통과시킨다
조건의 결과는 참 · 거짓 · UNKNOWN 세 가지이고, 거짓과 UNKNOWN은 모두 탈락이다.
(3) NULL과 빈 문자열은 다르다
MariaDB·MySQL에서 ''은 NULL이 아니다. 빈 문자열까지 빼라는 별도 요구가 있을 때만AND NAME <> ''를 덧붙인다.
(4) 필터와 정렬은 따로 생각한다
WHERE는 "어떤 행을", ORDER BY는 "어떤 순서로"를 정한다. → 🗄️ 15. ORDER BY
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
WHERE NAME != NULL 로 쓰면 이름이 있는 동물은 나올까?
한 마리도 안 나옵니다. 이름이 '체리'인 행에서도 '체리' != NULL은 참이 아니라 UNKNOWN이기 때문입니다. WHERE는 참인 행만 통과시키죠. 그래서 NULL을 다룰 때는 항상 IS NOT NULL입니다.
이름이 빈 문자열인 A400을 무조건 빼야 할까?
아닙니다. 문제의 NULL 조건을 먼저 따릅니다. 빈 문자열까지 제외하라는 별도 요구가 있을 때만 조건을 추가합니다.
💼 이 유형을 다시 만나면"있는 것"을 찾는 문제는 IS NOT NULL, "없는 것"을 찾는 문제는 IS NULL — 17회차 문제와 한 쌍으로 기억하세요. 실무에서 주의할 점은 DB마다 빈 문자열 처리가 다르다는 것입니다. MariaDB·MySQL에서는 ''과 NULL이 다르지만, Oracle은 빈 문자열을 NULL로 취급합니다. 그래서 같은 쿼리라도 DB를 옮기면 결과 건수가 달라질 수 있고, 데이터를 저장할 때 "값 없음"을 NULL과 '' 중 하나로 통일해 두는 편이 안전합니다.
정리 작업 메모
제출 SQL은 18회차 오답노트 원문 그대로 파일로 보존했다(Back_end/coding-tests/2026-09-23/named-animal-ids.sql). 원래 배지는 "PASS · 원문 기록"이었다.
실행 결과는 Sql18Verification.java가 verify-fixtures.sql의 세션 임시 테이블을 만든 뒤 제출 SQL 파일을 직접 읽어 실행한 것이다(MariaDB 12.3.3, 8개 검사 통과). 기존 수업 테이블은 변경하지 않았고, 프로그래머스 재채점 결과가 아니다.
18회차
헤비 유저가 소유한 장소
SQLGROUP BY · HAVING서브쿼리 INWHERE vs HAVING
한 줄 요약호스트별 개수는 서브쿼리에서 GROUP BY + HAVING으로 세고, 그 호스트의 모든 장소는 바깥 쿼리에서 IN으로 가져온다.
쉽게 말하면먼저 장소를 둘 이상 가진 사람의 명단을 만든 뒤, 그 명단에 든 사람의 장소를 하나도 빠뜨리지 않고 꺼낸다.
숙소 테이블 PLACES에서 공간을 둘 이상 등록한 사람을 "헤비 유저"라고 할 때, 헤비 유저가 등록한 모든 공간의 ID·이름·호스트 ID를 공간 ID 순으로 조회하는 문제.
SQL제출 코드
SELECT ID, NAME, HOST_ID
FROM PLACES
WHERE HOST_ID IN (
SELECT HOST_ID
FROM PLACES
GROUP BY HOST_ID
HAVING COUNT(*) >= 2
)
ORDER BY ID ASC;
💡 풀이 흐름
"명단 만들기"와 "장소 꺼내기"를 두 단계로 나눴다.
1. 안쪽 서브쿼리 — 헤비 유저 명단 — GROUP BY HOST_ID로 사람별로 묶고 HAVING COUNT(*) >= 2로 그룹을 고른다. 결과는 HOST_ID 목록이다.
2. 바깥 쿼리 — 그 사람들의 장소 전부 — WHERE HOST_ID IN (…)은 장소 행을 걸러낸다. 결과가 여러 호스트일 수 있어 = 대신 IN으로 비교한다.
3. 정렬 — 최종 정렬 기준은 호스트 번호가 아니라 공간 ID다.
왜 두 단계로 나누나 — 바깥 쿼리 자체를 HOST_ID로 묶으면 장소별 행을 유지하지 못한다. 묶는 순간 호스트 하나당 한 행으로 줄어들기 때문이다. 그룹 기준을 판단하는 단계와 원래 행을 반환하는 단계를 분리하는 이유다.
>= 2이므로 정확히 2개인 호스트도 포함한다. "둘 이상"을 > 2로 옮기면 틀린다.
실행 결과제출 SQL을 임시 테이블 픽스처에 실행 (MariaDB 12.3.3) 먼저 예측 → 펼쳐서 확인
픽스처: 호스트 10 → 장소 10, 90 (2개)
호스트 20 → 장소 50 (1개)
호스트 30 → 장소 40, 60, 70 (3개)
반환 ID: 10, 40, 60, 70, 90 ← 공간 ID 순서
각 호스트가 1개씩만 남은 경우: 0건
빈 테이블: 0건
장소가 정확히 2개인 호스트 10의 장소(10, 90)가 들어 있고, 1개뿐인 호스트 20의 장소 50은 빠졌습니다. 결과가 호스트별로 모이지 않고 공간 ID 순으로 섞여 나오는 것도 확인할 수 있습니다.
📌 주요 개념 정리
(1) WHERE는 행, HAVING은 그룹
같은 쿼리 단계의 WHERE는 묶기 전 개별 행을 거르고, HAVING은 묶은 뒤 그룹의 집계 결과를 거른다. COUNT(*) >= 2는 묶어 봐야 알 수 있는 값이라 HAVING이다. → 🗄️ 19. HAVING vs WHERE(2) GROUP BY는 행을 줄인다
묶은 뒤에는 그룹당 한 행만 남는다. 원래 행이 모두 필요하면 묶는 일은 서브쿼리 안에서만 한다. → 🗄️ 18. GROUP BY(3) 결과가 여러 개인 서브쿼리는 IN
서브쿼리가 여러 행을 돌려줄 수 있으면 =가 아니라 IN으로 받는다. → 🗄️ 13. 다중행 서브쿼리 — IN · ANY · ALL(4) 같은 일을 하는 다른 방법COUNT(*) OVER (PARTITION BY HOST_ID)처럼 윈도우 함수를 쓰면 행을 줄이지 않고 각 행 옆에 호스트별 개수를 붙일 수 있다(MySQL 8 이상·MariaDB). 그 값을 바깥에서 2 이상으로 거르면 된다. → 🗄️ 28. 윈도우 함수 기초
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
COUNT(*) >= 2를 WHERE에 바로 적으면 될까?
안 됩니다. 같은 쿼리 단계의 WHERE는 개별 행을 거르고, HAVING은 그룹의 집계 결과를 거릅니다. WHERE가 실행되는 시점에는 아직 묶이지 않아 개수를 알 수 없죠. 이 풀이에서는 HAVING에 써야 합니다.
서브쿼리 없이 SELECT ID, NAME, HOST_ID … GROUP BY HOST_ID HAVING COUNT(*) >= 2 로 한 번에 쓰면?
DB 설정(ONLY_FULL_GROUP_BY)에 따라 에러가 나거나, 나오더라도 호스트당 한 행뿐입니다. 호스트 30의 장소 3개가 1행으로 줄어들죠. 문제는 헤비 유저의 모든 장소를 원하므로, 명단은 서브쿼리에서 만들고 행은 바깥에서 그대로 가져와야 합니다.
💼 이 유형을 다시 만나면"조건을 만족하는 그룹에 속한 행 전부"는 코딩테스트 SQL의 대표 패턴입니다(예: 주문이 3번 이상인 회원의 주문 내역 전부). 보이면 안쪽에서 GROUP BY + HAVING으로 명단 → 바깥에서 IN을 바로 떠올리세요. 실무에서는 "VIP 고객의 최근 주문 목록"처럼 같은 모양이 자주 나오고, 데이터가 많으면 IN 서브쿼리 대신 JOIN이나 윈도우 함수로 바꾸고 EXPLAIN으로 실행 계획을 비교하기도 합니다.
정리 작업 메모
제출 SQL은 18회차 오답노트 원문 그대로 파일로 보존했다(Back_end/coding-tests/2026-09-23/heavy-user-places.sql). 원래 배지는 "PASS · 원문 기록"이었다.
실행 결과는 Sql18Verification.java가 verify-fixtures.sql의 세션 임시 테이블을 만든 뒤 제출 SQL 파일을 직접 읽어 실행한 것이다. 두 SQL(ct-37과 이 카드) 합계 8개 검증 통과(MariaDB 12.3.3). 실제 수업 테이블은 변경하지 않았다.
윈도우 함수 대안은 개념 소개이며 이번 정리에서 실행해 보지는 않았다.
19회차
나머지가 1이 되는 수 찾기
JAVA나머지 연산약수완전 탐색
한 줄 요약n을 x로 나눈 나머지가 1이면 x는 n−1을 나누어떨어지게 한다. 작은 수부터 차례로 나눠 보다가 처음 나머지가 1이 되는 수에서 break 하면 그것이 답이다.
쉽게 말하면사탕 n개를 x명에게 똑같이 나눠 주고 1개가 남으려면, 1개를 빼 둔 n−1개가 딱 나눠떨어져야 한다. 1명, 2명, 3명… 차례로 시도해 보고 처음 성공한 인원이 답이다.
class Solution {
public int solution(int n) {
int answer = 0;
for(int i=1;i<=n;i++){
if(n%i==1){ // ← 나머지가 1인 첫 번째 i
answer = i;
break; // ← 작은 쪽부터 돌았으니 처음 찾은 것이 최솟값
}
}
return answer;
}
}
💡 풀이 흐름
통과한 코드다. 작은 수부터 하나씩 나눠 보는 완전 탐색으로 풀었다.
1. i = 1부터 시작해도 괜찮다 — n % 1은 항상 0이라 조건에 걸리지 않는다. 실제로 의미 있는 검사는 i = 2부터다.
2. 처음 걸린 i에서 break — 작은 쪽부터 올라가므로 처음 찾은 값이 곧 가장 작은 x다.
3. 답이 반드시 있다 — n ≥ 3이면 n−1 ≥ 2이고, n % (n−1)은 항상 1이다. 그래서 반복은 늦어도 i = n−1에서 끝난다.
4. 가장 오래 도는 경우 — n−1이 소수일 때다. 2부터 n−2까지는 n−1을 나누지 못하므로 n−1번 가까이 돈다. 그래도 n ≤ 1,000,000이라 제한 시간 안이다.
[같은 조건을 약수로 바꿔 쓰면]
if ((n - 1) % i == 0) { ... } // i는 2부터
"n을 i로 나눈 나머지가 1"과 "n−1이 i로 나누어떨어진다"는 i ≥ 2에서 같은 말이다. 답 = n−1의 약수 중 1을 뺀 가장 작은 것(= n−1의 가장 작은 소인수).
📌 주요 개념 정리
(1) 나머지 문제를 약수 문제로 바꾸기
"n을 x로 나눈 나머지가 r" ⇔ "(n−r)이 x로 나누어떨어진다"(x > r일 때). 나머지 조건이 나오면 먼저 이렇게 바꿔 보면 약수·배수 문제가 된다.
(2) 최솟값 탐색 = 작은 쪽부터 + break
"가장 작은 ~"을 찾을 때는 작은 값부터 돌다가 처음 조건을 만족하는 순간 멈춘다. break가 없으면 answer가 계속 덮어써져 마지막에 걸린 값이 남는다. → ☕ 09. 반복문과 break(3) %(나머지) 연산자a % b는 a를 b로 나눈 나머지다. n % 1 == 0, n % n == 0은 항상 성립하므로 1과 n 자신은 나머지 1을 만들 수 없다. → ☕ 07. 연산자 총정리(4) 입력 크기로 방법을 고른다
n이 최대 100만이면 한 번 쭉 도는 반복(O(n))은 충분히 빠르다. 입력이 훨씬 컸다면 √(n−1)까지만 약수를 찾는 식으로 줄여야 했을 것이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
반복을 i = 1부터 시작했는데, 왜 답이 1로 나오지 않을까?
어떤 수든 1로 나누면 나머지가 0이기 때문입니다. n % 1 == 1은 절대 참이 될 수 없으니 i = 1은 그냥 지나가고, 실제 검사는 2부터 의미가 있습니다.
제출 코드에서 break를 지우면 어떤 값이 반환될까?
조건에 걸릴 때마다 answer가 덮어써져 마지막으로 걸린 값이 남습니다. 나머지가 1이 되는 가장 큰 수는 n−1이므로(n % n은 0) 항상 n−1이 반환되죠. n−1이 소수일 때만 우연히 맞고 나머지는 틀립니다.
"답이 항상 존재한다"고 말할 수 있는 근거는?
n ≥ 3이면 x = n−1이 항상 조건을 만족합니다. n = 1 × (n−1) + 1이니까 n을 n−1로 나누면 몫 1, 나머지 1이죠. 그래서 반복은 늦어도 n−1에서 반드시 멈춥니다.
💼 이 유형을 다시 만나면"나머지가 r"을 "(n−r)의 약수"로 바꾸는 발상은 약수의 합(ct-29), 최대공약수, 소수 판별 같은 수학 유형에 그대로 쓰입니다. % 연산은 실무에서도 자주 나옵니다 — 전체 글 수를 페이지 크기로 나눠 남는 글이 있는지 볼 때, 짝수 줄·홀수 줄에 다른 색을 칠할 때, 작업을 번호 % 서버 수로 나눠 맡길 때가 모두 같은 원리입니다.
정리 작업 메모
원문 결과는 PASS(오답노트 원문 기록). 원문의 제목은 '나머지가 1이 되는 수'로 축약되어 있어 공식 문제명을 썼다.
제출 코드는 Back_end/coding-tests/2026-09-28/RemainderOne.java에 보존. 파일로 옮기며 클래스 이름만 파일 이름에 맞췄다.
로컬 검증(프로그래머스 재채점 아님) — 3~50,000 전수 + 무작위 20,000 + 최악(n−1이 소수) 20개, 70,020개 입력 통과. 기댓값은 에라토스테네스 체로 만든 "n−1의 가장 작은 소인수" 표로, 제출 코드와 계산 방식이 다르다. 상한 근처 최악 20개가 합쳐서 수십 ms.
19회차
크레인 인형뽑기 게임
JAVAStack2차원 배열시뮬레이션
한 줄 요약열은 고정하고 행을 위에서 아래로 내려가 처음 만난 인형 하나만 집는다. 바구니는 스택 — 맨 위 인형과 같으면 둘 다 사라지고, 사라진 인형 수(2개씩)를 센다.
쉽게 말하면뽑기 기계의 크레인은 한 번에 하나만 집고, 바구니에서는 맨 위에 올려둔 인형만 보인다. 방금 넣은 인형이 맨 위 인형과 같으면 둘이 함께 터진다.
N×N 격자 board(0은 빈칸, 1~100은 인형 종류)와 크레인을 내릴 열 번호 배열 moves(1부터 셈)가 주어진다. 크레인은 그 열의 가장 위 인형 하나를 집어 바구니에 쌓고, 같은 인형 두 개가 연달아 쌓이면 둘 다 사라진다. 빈 열이면 아무 일도 없다. 사라진 인형의 개수를 반환하는 문제.
import java.util.Stack;
class Solution {
public int solution(int[][] board, int[] moves) {
int answer = 0;
Stack<Integer> basket = new Stack<>();
for (int m : moves) {
int col = m - 1; // ← moves는 1부터 센 번호
for (int row = 0; row < board.length; row++) {
if (board[row][col] != 0) {
int doll = board[row][col];
board[row][col] = 0; // ← 집은 자리는 비운다
if (!basket.isEmpty() && basket.peek() == doll) {
basket.pop();
answer += 2; // ← 사라진 인형은 2개
} else {
basket.push(doll);
}
break; // ← 하나 집었으면 다음 명령으로
}
}
}
return answer;
}
}
💡 정답 풀이
제출 코드 칸이 비어 있어 실제로 어디서 틀렸는지는 알 수 없다. 그래서 여기서는 "어디서 틀렸는가"가 아니라 이 문제에서 흔히 틀리는 곳과 "무엇을 알아야 풀 수 있는가"를 정리한다.
흔히 틀리는 네 곳
1. int col = m - 1; — moves는 사람 기준 1번 칸부터 센다. 배열은 0번부터이므로 1을 빼야 한다.
2. board[row][col] = 0; — 집은 자리를 비우지 않으면 같은 열을 다시 뽑을 때 같은 인형이 또 나온다.
3. 안쪽 반복의 break — 없으면 한 번에 그 열의 인형을 전부 집는다. 공식 예제에서 break만 빼면 답이 4가 아니라 6이 된다(아래 실행 결과).
4. answer += 2 — 세는 것은 "터진 횟수"가 아니라 "사라진 인형 수"다. 한 번 터지면 2개.
풀이 흐름
1. 바깥 반복은 명령(moves)을 하나씩 실행한다.
2. 안쪽 반복은 그 열을 위(0행)부터 아래로 내려가며 0이 아닌 첫 칸을 찾는다.
3. 찾은 인형을 바구니 맨 위(peek)와 비교해 같으면 pop + 2, 다르면 push.
4. 하나 집었으면 break로 다음 명령으로 넘어간다. 끝까지 못 찾으면(빈 열) 아무 일도 하지 않는다.
⚠️ == 비교 주의 — basket.peek() == doll에서 peek()은 Integer, doll은 int라 값을 풀어서(언박싱) 비교한다. 둘 다 Integer였다면 ==는 값이 아니라 주소를 비교한다. 이 문제는 인형 번호가 100 이하라 캐시 범위(−128~127) 안에서 우연히 맞겠지만, 객체끼리는 equals가 안전하다. → 🧱 10. final · 상수와 Wrapper 클래스
실행 결과공식 예제로 정답 풀이와 break를 뺀 형태를 비교 먼저 예측 → 펼쳐서 확인
정답 풀이 → 4 (기대값 4)
안쪽 break 를 뺀 형태 → 6 ← 한 번에 그 열의 인형을 여러 개 집는다
"break를 뺀 형태"는 break의 역할을 보여 주려고 만든 비교용 시연입니다. 실제 제출본이 아니며, 이것이 FAIL의 원인이라고 단정하지 않습니다.
📌 주요 개념 정리
(1) 스택(Stack) — 마지막에 넣은 것이 먼저 나온다
"방금 넣은 것과 비교"는 스택의 일이다. push(넣기) · peek(맨 위 보기, 꺼내지 않음) · pop(맨 위 꺼내기). peek·pop 전에는 isEmpty()로 비어 있는지 먼저 확인한다. &&는 앞이 거짓이면 뒤를 실행하지 않으므로 !basket.isEmpty() && basket.peek() == doll 순서가 안전하다.
(2) 2차원 배열에서 "열 하나를 위에서 아래로"board[row][col]에서 col을 고정하고 row만 늘리면 한 열을 위에서 아래로 훑는다. → 🧱 16. 배열(3) 시뮬레이션 = 바깥은 명령, 안쪽은 탐색
명령을 하나씩 처리하고, 탐색에서 원하는 것을 찾으면 break. 상태(보드·바구니)를 실제로 바꿔 가며 따라가는 것이 핵심이다.
(4) 배열과 ArrayList 문법 (오답노트 개념 정리)
길이는 arr.length(필드) vs list.size()(메서드), 원소는 arr[i] vs list.get(i). → 🛠️ 02. List 계열
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
집은 자리를 0으로 바꾸지 않으면 어떤 일이 생길까?
보드에 인형이 그대로 남아 있으니, 같은 열에 크레인을 다시 내리면 방금 집은 그 인형을 또 집습니다. 실제 기계라면 사라졌어야 할 인형이 무한히 나오는 셈이죠. 시뮬레이션에서는 상태를 바꾸는 줄을 빠뜨리면 안 됩니다.
basket.peek() == doll 이 여기서는 맞게 동작하는 이유는?
한쪽이 기본형 int라서 Integer 쪽을 값으로 풀어(언박싱) 비교하기 때문입니다. 양쪽이 모두 Integer 객체라면 ==는 주소 비교가 되어 127을 넘는 값에서 틀릴 수 있습니다. 객체끼리는 equals를 쓰는 습관이 안전합니다.
왜 터질 때마다 answer에 1이 아니라 2를 더할까?
문제가 묻는 것이 "사라진 인형의 개수"이기 때문입니다. 한 번 터지면 바구니 맨 위 인형과 방금 넣은 인형, 두 개가 사라지죠. 문제의 반환값이 무엇을 세는지 끝까지 읽어야 합니다.
💼 이 유형을 다시 만나면"방금 넣은 것과 짝이 맞으면 없앤다"는 스택의 대표 유형입니다. 괄호 짝 맞추기, 같은 글자 연속 제거 같은 문제가 모두 같은 틀입니다. 실무에서도 되돌리기(Undo) 기록이나 브라우저 뒤로 가기가 스택 구조입니다. 참고로 자바에서 새 코드는 Stack보다 ArrayDeque를 스택으로 쓰는 것이 권장됩니다(push·peek·pop 이름은 같다).
정리 작업 메모
원문 결과는 FAIL. 오답노트의 제출 코드 칸이 비어 있어 실패 원인을 단정하지 않았다. 정답 풀이는 Back_end/coding-tests/2026-09-28/CraneGame.java(클래스 이름만 바꿔 옮김). 카드의 코드는 클래스 이름을 Solution으로 두고 주석만 줄였다.
로컬 검증(프로그래머스 재채점 아님) — 공식 예제 = 4. 무작위 보드 20,000개 통과(N 5~30, moves 1~1000). 기댓값은 열마다 인형을 ArrayDeque에 담아 두고 꺼내는 별도 시뮬레이션, 인형은 바닥부터 쌓이도록 생성.
19회차
어린 동물 찾기 (원문 제목: 이름이 있는 동물의 아이디)
SQLWHERE<> (같지 않다)NULL
한 줄 요약"어린 것"을 직접 찾는 대신 "Aged가 아닌 것"으로 조건을 뒤집는다. <>는 "같지 않다"이고, NULL인 행은 <>에도 걸리지 않아 함께 빠진다는 점을 기억한다.
쉽게 말하면늙은 동물만 빼고 나머지를 전부 고른다. 다만 상태 칸이 비어 있는(NULL) 동물은 "늙었는지 모름"이라 같이 빠진다.
보호소에 들어온 동물 테이블 ANIMAL_INS에서 젊은 동물(보호 시작 때 상태 INTAKE_CONDITION이 'Aged'가 아닌 동물)의 아이디와 이름을 아이디 순으로 조회하는 문제.
기록 주의. 오답노트의 제목은 "이름이 있는 동물의 아이디"다. 하지만 그 문제는 18회차에서 이미 풀었고, 이번 제출 SQL(ANIMAL_ID·NAME 조회, INTAKE_CONDITION <> 'Aged', ID순)은 "어린 동물 찾기"의 요구사항과 정확히 일치한다. 제목 칸이 지난 회차 양식에서 남은 것으로 보고 공식 문제명을 썼다.
SELECT ANIMAL_ID, NAME
from ANIMAL_INS
where INTAKE_CONDITION <> 'Aged' -- ← "Aged가 아닌 것" = 젊은 동물
order by ANIMAL_ID;
💡 풀이 흐름
통과한 코드다. 조건을 뒤집어 짧게 풀었다.
1. 필요한 컬럼만 — 아이디와 이름 두 개만 SELECT.
2. 조건 뒤집기 — 상태 값이 여러 가지(Normal, Sick, Injured …)라도 "젊다"를 하나하나 나열할 필요 없이 <> 'Aged' 한 줄이면 된다.
3. 정렬 — ORDER BY ANIMAL_ID는 ASC를 생략한 오름차순이다.
⚠️ <>와 NULL<> 'Aged'는 INTAKE_CONDITION이 NULL인 행도 함께 뺀다. NULL과의 비교는 참도 거짓도 아닌 "알 수 없음"이라 WHERE를 통과하지 못한다. 이 문제는 해당 컬럼이 NOT NULL이라 상관없지만, NULL이 섞일 수 있다면 이렇게 따로 적는다.
WHERE INTAKE_CONDITION <> 'Aged'
OR INTAKE_CONDITION IS NULL
실행 결과상태가 NULL인 동물(A500)을 일부러 넣은 연습 데이터로 먼저 예측 → 펼쳐서 확인
제출 SQL → A100|NULL, A300|보리, A400|두부 (Aged인 A200 제외)
← 상태가 NULL인 A500도 빠졌다
OR INTAKE_CONDITION IS NULL → A100, A300, A400, A500
(비교) '이름이 있는 동물의 아이디' 정답 형태 → A200, A300, A400, A500
마지막 줄은 두 문제가 서로 다른 결과를 낸다는 것을 보여 줍니다. 이번 제출 SQL이 "이름이 있는 동물의 아이디"가 아니라 "어린 동물 찾기"의 답이라는 근거입니다.
📌 주요 개념 정리
(1) <>와 !=는 같다
둘 다 "같지 않다". 표준 SQL은 <>이고, MySQL·MariaDB는 !=도 받는다.
(2) NULL은 =에도 <>에도 걸리지 않는다
NULL과 무엇을 비교하든 결과는 "알 수 없음(UNKNOWN)"이고, WHERE는 참인 행만 통과시킨다. 없는 값은 IS NULL · IS NOT NULL로만 찾는다. → 🗄️ 14. NULL 함정(3) 조건 뒤집기
"A, B, C 중 하나"를 나열하기보다 "D가 아닌 것"이 짧으면 뒤집는다. 다만 뒤집으면 NULL이 빠진다는 것까지 같이 따져야 한다.
(4) 같은 테이블, 다른 질문이름이 없는 동물의 아이디·이름이 있는 동물의 아이디는 NAME IS NULL / IS NOT NULL을 묻고, 이 문제는 INTAKE_CONDITION의 값을 묻는다. 같은 ANIMAL_INS라도 어느 컬럼을 거르는지가 문제마다 다르다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
INTAKE_CONDITION이 NULL인 동물은 <> 'Aged' 조건을 통과할까?
통과하지 못합니다.NULL <> 'Aged'는 참이 아니라 "알 수 없음"이고, WHERE는 참인 행만 남기기 때문이죠. NULL도 포함하려면 OR INTAKE_CONDITION IS NULL을 따로 적어야 합니다.
오답노트 제목과 실제 문제가 다르다고 판단한 근거는?
제출 SQL의 조건이 근거입니다. "이름이 있는 동물의 아이디"라면 NAME IS NOT NULL로 거르고 아이디만 조회해야 하는데, 이번 SQL은 INTAKE_CONDITION <> 'Aged'로 거르고 아이디·이름을 조회합니다. 이것은 "어린 동물 찾기"의 요구사항과 정확히 같고, 실행해 보면 두 문제의 결과도 다릅니다.
💼 이 유형을 다시 만나면실무에서 "삭제되지 않은 글", "탈퇴하지 않은 회원"을 고를 때 status <> 'DELETED' 같은 부정 조건을 자주 씁니다. 이때 상태 컬럼이 NULL을 허용하면 상태가 비어 있는 데이터가 조용히 목록에서 사라집니다. 그래서 상태 컬럼은 NOT NULL + 기본값으로 설계하는 경우가 많습니다. 코딩테스트에서는 부정 조건(<>, NOT IN)을 쓸 때마다 "NULL이면?"을 한 번 떠올리세요.
정리 작업 메모
원문 결과는 PASS(오답노트 원문 기록). 원문 제목 문제는 위 "기록 주의" 참고 — 원문 제목은 h3에 괄호로 함께 남겼다.
제출 코드는 Back_end/coding-tests/2026-09-28/intake-not-aged.sql에 보존.
로컬 검증(프로그래머스 재채점 아님) — verify-sql19.mjs가 제출 SQL을 파일에서 그대로 읽어 Node 내장 SQLite(메모리 DB)에서 실행. 여기서 쓴 <>·IS NULL·NULL 비교 규칙은 MySQL·MariaDB와 같다. 로컬 DB 접속 정보는 쓰지 않았다. 실행 결과의 A100~A500은 차이를 보이려고 만든 연습 데이터다.
19회차
없어진 기록 찾기
SQLOUTER JOINIS NULL차집합
한 줄 요약OUTS를 기준으로 외부 조인하고 INS 쪽이 NULL인 행만 남기면 "OUTS에만 있는 것", 즉 차집합이 된다. INNER JOIN으로는 짝 없는 행이 이미 사라져 찾을 수 없다.
쉽게 말하면입양 명단 옆에 입소 명단을 나란히 붙였을 때, 옆 칸이 비어 있는 동물이 기록이 없어진 동물이다.
SELECT O.ANIMAL_ID, O.NAME -- ← 기준(O) 쪽 컬럼을 조회
from ANIMAL_INS I RIGHT JOIN ANIMAL_OUTS O ON I.ANIMAL_ID = O.ANIMAL_ID
where I.ANIMAL_ID is null -- ← 짝(I)이 없는 행만
ORDER BY O.ANIMAL_ID;
💡 풀이 흐름
통과한 코드다. 차집합을 구하는 대표 패턴을 그대로 썼다.
1. 기준 정하기 — 찾는 동물은 OUTS에는 있고 INS에는 없다. 그러니 OUTS를 기준으로 삼는다.
2. 외부 조인 — ANIMAL_INS I RIGHT JOIN ANIMAL_OUTS O는 오른쪽(O)의 행을 짝이 없어도 전부 살린다. 짝이 없으면 I 쪽 컬럼은 모두 NULL로 채워진다.
3. 짝 없는 행만 남기기 — WHERE I.ANIMAL_ID IS NULL.
4. O 쪽 컬럼을 SELECT — 남은 행의 I 쪽은 전부 NULL이므로 아이디·이름은 O에서 가져와야 한다.
[같은 뜻, 더 흔히 쓰는 모양 — LEFT JOIN]
SELECT O.ANIMAL_ID, O.NAME
FROM ANIMAL_OUTS O LEFT JOIN ANIMAL_INS I ON O.ANIMAL_ID = I.ANIMAL_ID
WHERE I.ANIMAL_ID IS NULL
ORDER BY O.ANIMAL_ID;
A RIGHT JOIN B = B LEFT JOIN A. 기준 테이블을 왼쪽에 두는 LEFT JOIN이 읽기 쉬워 더 자주 쓴다. NOT EXISTS로도 같은 결과가 나온다.
실행 결과같은 연습 데이터에 여러 쿼리를 나란히 먼저 예측 → 펼쳐서 확인
제출 SQL (RIGHT JOIN) → A700|해피, A900|누리
OUTS LEFT JOIN INS → A700|해피, A900|누리
NOT EXISTS → A700|해피, A900|누리
INNER JOIN + IS NULL → 0행 ← 짝 없는 행은 조인에서 이미 사라졌다
SELECT I.ANIMAL_ID, I.NAME → NULL|NULL, NULL|NULL ← I 쪽은 비어 있다
앞의 세 쿼리는 모양만 다르고 같은 질문입니다. 넷째 줄은 INNER JOIN으로는 이 문제를 풀 수 없다는 것을, 다섯째 줄은 SELECT를 O 쪽에서 해야 하는 이유를 보여 줍니다.
📌 주요 개념 정리
(1) 차집합 A − B = A 기준 외부 조인 + WHERE B.키 IS NULL
"A에는 있는데 B에는 없는 것"은 거의 항상 이 모양이다. → 🗄️ 38. OUTER JOIN 실전(2) INNER JOIN은 짝이 있는 행만 남긴다
짝이 없는 행은 조인 단계에서 이미 사라지므로, 그 뒤에 IS NULL로 거를 대상 자체가 없다. → 🗄️ 33. OUTER JOIN(3) 조인하면 같은 이름의 컬럼이 두 개 생긴다ANIMAL_ID·NAME이 양쪽에 다 있다. 항상 별칭(O., I.)을 붙여 어느 쪽인지 밝힌다.
(4) NOT IN으로 풀 때의 함정WHERE ANIMAL_ID NOT IN (SELECT ANIMAL_ID FROM ANIMAL_INS)도 같은 뜻이지만, 서브쿼리 결과에 NULL이 하나라도 섞이면 0행이 된다. 외부 조인이나 NOT EXISTS는 이 문제가 없다. → 🗄️ 14. NULL 함정(5) 17회차와 비교있었는데요 없었습니다는 양쪽에 다 있는 동물을 INNER JOIN으로 비교했다. 이번에는 한쪽에만 있는 동물을 찾는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
INNER JOIN 뒤에 WHERE I.ANIMAL_ID IS NULL을 붙이면 왜 0행일까?
INNER JOIN은 양쪽에 짝이 있는 행만 남기기 때문입니다. 짝이 있는 행의 I.ANIMAL_ID는 NULL일 수가 없으니 조건을 만족하는 행이 하나도 없죠. 찾고 싶은 "짝 없는 행"은 조인 단계에서 이미 사라졌습니다.
SELECT를 I.ANIMAL_ID, I.NAME으로 쓰면 무엇이 나올까?
행 수는 맞지만 값이 전부 NULL로 나옵니다. 남은 행은 "I 쪽에 짝이 없는 행"이라 I 쪽 컬럼이 모두 NULL로 채워져 있기 때문이죠. 기준 테이블(O)의 컬럼을 조회해야 합니다.
💼 이 유형을 다시 만나면"A에는 있는데 B에는 없는"은 코딩테스트 SQL의 단골입니다(가입했지만 주문이 없는 회원, 등록했지만 판매되지 않은 상품 등). 실무에서는 데이터 정합성 점검에 그대로 쓰입니다 — 부모 행이 사라진 고아 레코드를 찾거나, 두 시스템 사이에서 한쪽에만 들어간 데이터를 찾을 때 외부 조인 + IS NULL을 돌립니다.
정리 작업 메모
원문 결과는 PASS(오답노트 원문 기록). 제출 코드는 Back_end/coding-tests/2026-09-28/missing-intake-records.sql에 보존.
로컬 검증(프로그래머스 재채점 아님) — verify-sql19.mjs로 SQLite(Node 내장, 메모리 DB)에서 확인했다. 여기서 쓴 JOIN·IS NULL·NOT EXISTS의 동작은 MySQL·MariaDB와 같다. 로컬 DB 접속 정보는 쓰지 않았다. 실행 결과의 A700·A900은 연습 데이터다.
20회차
x만큼 간격이 있는 n개의 숫자
JAVAlong오버플로형 변환
한 줄 요약앞의 값에 x를 계속 더한다. 마지막 값이 최대 100억이라 int(약 21억)로는 담을 수 없어 반환 타입이 long[]이고, 곱셈으로 구할 때는 곱하기 전에 long으로 바꿔야 한다.
쉽게 말하면계단을 한 칸에 x씩 오르는데, 1,000칸을 오르면 int라는 건물 높이를 뚫고 나간다. 처음부터 더 높은 건물(long)에서 올라야 한다.
class Solution {
public long[] solution(int x, int n) {
long[] answer = new long[n]; // ← 결과를 long으로 담는다
answer[0]=x; // int → long 은 자동 변환
for(int i=1;i<answer.length;i++){
answer[i]= answer[i-1]+x; // ← long + int → long 계산
}
return answer;
}
}
💡 풀이 흐름
통과한 코드다. 앞의 값에 x를 더해 가는 누적으로 풀었다.
1. 첫 값 — answer[0] = x. int를 long 칸에 넣는 것은 더 큰 타입으로 가는 변환이라 자동으로 된다.
2. 누적 — answer[i] = answer[i-1] + x. answer[i-1]이 이미 long이라 long + int는 long으로 계산된다. 그래서 중간에 넘칠 일이 없다.
3. 반환 타입이 long[]인 이유 — x는 최대 1천만, n은 최대 1,000이라 마지막 값은 최대 100억이다. int는 약 21억(2,147,483,647)까지라 담을 수 없다.
[곱셈으로 구한다면 — 순서 주의]
answer[i] = (long) x * (i + 1); // ✅ 곱하기 전에 long으로
answer[i] = (long) (x * (i + 1)); // ❌ int끼리 곱해 이미 넘친 뒤에 변환
괄호 위치 하나로 결과가 달라진다. 아래 실행 결과에서 확인해 보자.
실행 결과x = 10,000,000, n = 1000 일 때 마지막 값 먼저 예측 → 펼쳐서 확인
x * n (int끼리) → 1410065408 넘침
(long)(x * n) (넘친 뒤 변환) → 1410065408 이미 늦음
(long) x * n (곱하기 전 변환) → 10000000000
제출 코드 마지막 값 → 10000000000
에러 없이 엉뚱한 값이 나옵니다. int 곱셈 결과가 21억을 넘으면 자바는 예외를 내지 않고 넘친 부분을 버린 값을 돌려줍니다. (long)(x * n)은 괄호 안의 int 곱셈이 먼저 끝나 이미 망가진 값을 long으로 바꾸는 것이라 늦습니다.
📌 주요 개념 정리
(1) 값의 범위부터 본다int는 약 ±21억, long은 약 ±922경. "최대값 × 개수"가 21억을 넘으면 long이다. 문제의 반환 타입이 long이면 그 자체가 힌트다.
(2) 산술 연산의 타입은 피연산자가 정한다int * int는 int로 계산되고, 한쪽이라도 long이면 다른 쪽도 long으로 올려 계산한다. 결과를 담는 변수가 long이라도 계산은 이미 int로 끝난 뒤일 수 있다. → ☕ 06. 형 변환(3) 오버플로는 조용하다
자바의 정수 연산은 범위를 넘어도 예외를 던지지 않는다. 그래서 테스트의 큰 입력에서만 틀리고 작은 예제는 통과하는 경우가 많다.
(4) 작은 → 큰 타입은 자동, 큰 → 작은 타입은 명시long v = x;는 그냥 되지만 int v = someLong;은 컴파일 에러(possible lossy conversion)다. → ⚠️ lossy conversion
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
(long)(x * n) 으로 바꿨는데도 값이 틀리는 이유는?
괄호 안의 x * n이 먼저 int끼리 계산되기 때문입니다. 이 순간 21억을 넘어 값이 이미 망가지고, 그 망가진 값을 long으로 바꿔 봐야 원래 값은 돌아오지 않습니다. (long) x * n처럼 곱하기 전에 한쪽을 long으로 만들어야 합니다.
제출 코드는 형 변환을 따로 하지 않았는데 왜 안전할까?
더하는 왼쪽 값 answer[i-1]이 이미 long이기 때문입니다. long + int는 int 쪽을 long으로 올려 계산하므로, 더하는 순간부터 long 범위에서 계산됩니다.
💼 이 유형을 다시 만나면코딩테스트에서 반환 타입이 long이거나 입력이 수천만·수억 단위면 오버플로부터 의심하세요. 실무에서도 금액 합계, 조회수 누적, 밀리초 단위 시간(System.currentTimeMillis()는 long)처럼 큰 수가 쌓이는 곳은 처음부터 long으로 잡습니다. 면접에서 "int 곱셈 결과를 long 변수에 담으면 안전한가?"는 자주 나오는 질문이고, 답은 "계산이 int로 끝나므로 안전하지 않다"입니다.
정리 작업 메모
원문 결과는 PASS(오답노트 원문 기록). 제출 코드는 Back_end/coding-tests/2026-09-30/XInterval.java에 보존(클래스 이름만 파일 이름에 맞춤).
로컬 검증(프로그래머스 재채점 아님) — 경계 x 6개(±1천만·±1·0·2) × n 1~1000 + 무작위 20,000 = 26,000개 통과. 기댓값은 (long) x * (i+1) 곱셈으로 따로 계산.
20회차
숫자 문자열과 영단어
JAVAString 불변replaceparseInt
한 줄 요약영단어 배열의 인덱스가 곧 숫자다. zero~nine을 차례로 숫자로 바꾼 뒤 Integer.parseInt 한다. String은 불변이라 s = s.replace(…)로 다시 담아야 반영된다.
쉽게 말하면"one4seveneight"에서 단어들을 번호표로 바꿔 끼우면 "1478"이 남는다. 다만 String은 고쳐 쓰는 종이가 아니라, 고칠 때마다 새 종이를 받는 것이라 새 종이를 손에 쥐어야 한다.
class Solution {
public int solution(String s) {
String[] words = {"zero", "one", "two", "three", "four",
"five", "six", "seven", "eight", "nine"}; // ← 인덱스 = 숫자
for (int i = 0; i < words.length; i++) {
s = s.replace(words[i], Integer.toString(i)); // ← 다시 담아야 반영된다
}
return Integer.parseInt(s);
}
}
💡 정답 풀이
제출 코드 칸이 비어 있어 실제로 어디서 틀렸는지는 알 수 없다. 아래는 이 문제에서 흔히 틀리는 곳과 정답 풀이의 흐름이다.
흔히 틀리는 곳
1. s.replace(…)만 쓰고 다시 담지 않기 — String은 불변이라 원본 s는 그대로다. 그 상태로 parseInt 하면 영문자가 남아 NumberFormatException이 난다.
2. 한 글자씩 읽으며 직접 단어를 잘라 내기 — 단어 길이가 3~5글자로 제각각이라 인덱스 계산이 꼬이기 쉽다. replace를 열 번 부르는 편이 훨씬 단순하다.
3. 문자열을 그대로 반환하기 — 반환 타입은 int다. 마지막에 Integer.parseInt로 바꿔야 한다.
풀이 흐름
1. 영단어를 숫자 순서대로 배열에 넣는다. 그러면 words[7]이 "seven"이므로, 인덱스 i가 곧 그 단어의 숫자다 — 따로 대응표가 필요 없다.
2. i = 0부터 9까지 돌며 s = s.replace(words[i], Integer.toString(i))로 그 단어가 나오는 곳을 모두 숫자로 바꾼다.
3. 숫자만 남은 문자열을 Integer.parseInt로 int로 바꿔 반환한다. 반환값은 최대 20억이라 int(최대 약 21.47억)에 들어간다.
실행 결과다시 담을 때와 안 담을 때 먼저 예측 → 펼쳐서 확인
String s = "banana";
s.replace("a", "o"); → s 는 여전히 banana
s = s.replace("a", "o"); → s 는 bonono
정답 풀이("one4seveneight") → 1478
replace는 원본을 고치지 않고 바뀐 새 문자열을 돌려줄 뿐입니다. 돌려받은 값을 변수에 담지 않으면 그대로 버려집니다.
📌 주요 개념 정리
(1) String은 불변(immutable)이다replace·substring·toUpperCase 같은 String 메서드는 원본을 바꾸지 않고 새 문자열을 돌려준다. 결과를 쓰려면 반드시 변수에 다시 담는다. → 🧱 12. String의 특징(2) replace vs replaceAllreplace는 글자 그대로 찾아 모두 바꾸고, replaceAll은 첫 인자를 정규식으로 해석한다. 단순 치환이면 replace가 맞다. ("replace는 첫 번째만 바꾼다"는 오해가 많은데, 첫 번째만 바꾸는 것은 replaceFirst다.) → 🧱 13. String 주요 메서드(3) "배열 인덱스 = 값"이 되도록 배열을 만든다
0~9처럼 연속된 번호에 대응하는 값이면 배열 순서 자체가 대응표가 된다.
(4) Integer.parseInt와 범위
숫자만 남은 문자열을 int로 바꾼다. 영문자가 남아 있으면 NumberFormatException. 결과가 int 범위를 넘을 수 있으면 Long.parseLong을 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
반복문 안에서 s = 를 빼고 s.replace(…)만 쓰면 어떤 일이 일어날까?
s는 처음 문자열 그대로 남습니다. String은 불변이라 replace가 만든 새 문자열은 받을 변수가 없어 버려지죠. 결국 Integer.parseInt("one4seveneight")이 실행되어 NumberFormatException이 납니다.
"zero"~"nine"을 숫자로 바꾸는 대응표를 따로 만들지 않아도 되는 이유는?
배열을 숫자 순서대로 만들었기 때문입니다. words[i]의 인덱스 i가 곧 그 단어가 뜻하는 숫자이므로, Integer.toString(i)가 바꿔 넣을 값이 됩니다.
💼 이 유형을 다시 만나면"문자열 일부를 다른 값으로 바꾼 뒤 숫자로 변환"은 문자열 유형의 기본입니다. String 불변성은 면접 단골 질문이기도 합니다("String을 반복문에서 계속 더하면 왜 느린가?" → 매번 새 객체가 생기므로 StringBuilder). 실무에서도 전화번호의 하이픈 제거, 템플릿 문자열의 자리표시자 치환처럼 replace 결과를 다시 담는 일이 매일 나옵니다. → String vs StringBuilder
정리 작업 메모
원문 결과는 FAIL. 오답노트의 제출 코드 칸이 비어 있어 실패 원인을 단정하지 않았다. "흔히 틀리는 곳"은 이 문제의 일반적인 함정이다.
정답 풀이는 Back_end/coding-tests/2026-09-30/NumberWords.java(클래스 이름만 바꿔 옮김). 카드의 코드는 클래스 이름을 Solution으로 두고 주석만 줄였다.
로컬 검증(프로그래머스 재채점 아님) — 공식 예제 4개 + 문제가 입력을 만드는 방식대로(자릿수마다 숫자·영단어를 무작위로 골라) 만든 50,000개 + 경계 4개(전부 영단어인 20억 "twozerozerozerozerozerozerozerozerozero" 포함) 통과.
20회차
동물의 아이디와 이름
SQLSELECTORDER BY
한 줄 요약필요한 두 컬럼만 고르고 ID 순으로 정렬한다. ORDER BY에서 ASC는 생략해도 오름차순이고, ORDER BY가 없으면 순서는 보장되지 않는다.
쉽게 말하면명부에서 번호와 이름 칸만 옮겨 적고 번호 순서대로 세운다. "순서대로"라고 말하지 않으면 DB는 아무 순서로나 건네줄 수 있다.
SELECT ANIMAL_ID, NAME -- ← 필요한 컬럼만 쉼표로
from ANIMAL_INS
order by ANIMAL_ID; -- ← ASC 생략 = 오름차순
💡 풀이 흐름
통과한 코드다. SQL의 가장 기본 뼈대 그대로다.
1. SELECT 컬럼, 컬럼 — 문제가 요구한 두 컬럼만 쉼표로 나열한다. SELECT *를 쓰면 다른 컬럼까지 나와 채점에서 틀린다.
2. FROM 테이블 — 어느 표에서 꺼낼지.
3. ORDER BY ANIMAL_ID — 아이디 오름차순. ASC는 기본값이라 생략했다.
제출 코드처럼 from·order by를 소문자로 써도 된다. SQL 키워드는 대소문자를 구분하지 않는다. 다만 한 쿼리 안에서는 한 가지로 맞추는 편이 읽기 좋다.
📌 주요 개념 정리
(1) SELECT는 필요한 컬럼만
문제의 출력 컬럼과 개수·순서가 정확히 같아야 정답이다. → 🗄️ 02. SELECT ~ FROM(2) ORDER BY 컬럼 = ORDER BY 컬럼 ASC
내림차순만 DESC를 적는다. → 🗄️ 15. ORDER BY(3) ORDER BY가 없으면 순서는 약속되지 않는다
테이블에 넣은 순서나 기본 키 순서로 나오는 것처럼 보여도, 그것은 우연이다. 실행 계획이 바뀌면 순서도 바뀔 수 있다. 순서가 필요하면 반드시 ORDER BY를 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
ORDER BY를 빼도 결과가 아이디 순으로 나왔다면, 빼도 되는 걸까?
안 됩니다. ORDER BY가 없으면 DB는 순서를 약속하지 않습니다. 지금은 우연히 아이디 순으로 나왔어도, 데이터가 늘거나 실행 방식이 바뀌면 다른 순서로 나올 수 있습니다. 순서가 요구사항이면 항상 적어야 합니다.
SELECT * 로 조회하면 왜 틀릴까?
채점은 결과 표 전체를 비교하기 때문입니다. *는 테이블의 모든 컬럼을 가져오므로 요구하지 않은 컬럼이 섞여 표 모양이 달라집니다.
💼 이 유형을 다시 만나면실무에서 목록 화면을 만드는 쿼리에는 반드시 ORDER BY를 씁니다. 특히 페이징할 때 정렬이 없으면 페이지를 넘길 때 같은 글이 두 번 나오거나 빠지는 일이 생깁니다. SELECT * 대신 필요한 컬럼만 적는 습관도 같은 이유로 중요합니다 — 테이블에 컬럼이 추가돼도 화면이 흔들리지 않고, 가져오는 데이터도 줄어듭니다.
정리 작업 메모
원문 결과는 PASS(오답노트 원문 기록). 제출 코드는 Back_end/coding-tests/2026-09-30/animal-id-name.sql에 보존.
20회차
대여 기록이 존재하는 자동차 리스트 구하기
SQLJOINDISTINCT1:N 관계
한 줄 요약1:N 조인은 "1" 쪽 행을 대여 기록 수만큼 늘린다. 문제가 "중복 없이"를 요구하므로 SELECT DISTINCT가 필요했다 — 제출 코드와 정답의 차이는 이 한 단어다.
쉽게 말하면10월에 세 번 빌려 간 차는 대여 장부에 세 줄로 적혀 있다. 장부를 보며 차 목록을 만들 땐 같은 차를 한 번만 적어야 한다.
SELECT H.CAR_ID -- ← DISTINCT 가 없다
FROM CAR_RENTAL_COMPANY_CAR C JOIN CAR_RENTAL_COMPANY_RENTAL_HISTORY H ON
C.CAR_ID = H.CAR_ID
WHERE C.CAR_TYPE = '세단' AND MONTH(H.START_DATE) = 10
ORDER BY H.CAR_ID DESC;
💡 정답 풀이
틀린 곳은 하나 — SELECT 뒤의 DISTINCT를 빠뜨렸다.
자동차 한 대는 여러 번 대여될 수 있다(1:N). 자동차 표와 대여 기록 표를 조인하면 대여 기록 수만큼 같은 차가 여러 줄이 된다. 조인·조건·정렬은 모두 맞았지만, 문제는 "자동차 ID 리스트는 중복이 없어야 한다"고 요구한다.
[정답 코드]
SELECT DISTINCT H.CAR_ID
FROM CAR_RENTAL_COMPANY_CAR C JOIN CAR_RENTAL_COMPANY_RENTAL_HISTORY H ON
C.CAR_ID = H.CAR_ID
WHERE C.CAR_TYPE = '세단' AND MONTH(H.START_DATE) = 10
ORDER BY H.CAR_ID DESC;
풀이 흐름
1. 자동차(C)와 대여 기록(H)을 CAR_ID로 조인한다.
2. C.CAR_TYPE = '세단', MONTH(H.START_DATE) = 10으로 거른다 — 시작일 기준이라 9월에 시작해 10월에 끝난 대여는 빠진다.
3. DISTINCT로 같은 차를 한 줄로 줄인다.
4. ORDER BY H.CAR_ID DESC.
참고 — 날짜 조건
이 문제의 데이터는 2022년뿐이라 월만 봐도 되지만, 여러 해가 섞이면 YEAR도 함께 보거나 DATE_FORMAT(START_DATE, '%Y-%m') = '2022-10'처럼 연·월을 같이 비교한다.
실행 결과같은 세단이 10월에 세 번 빌려진 연습 데이터로 먼저 예측 → 펼쳐서 확인
제출 코드 (DISTINCT 없음) → 3, 3, 3, 1 ← 같은 차가 세 줄
정답 풀이 (DISTINCT) → 3, 1
GROUP BY H.CAR_ID → 3, 1
차마다 10월 대여 횟수 → 3|3, 1|1
9월 시작 · 10월 종료 대여 → 제외 (시작일 기준)
3번 차는 10월 대여 기록이 3건이라 조인 결과에 세 번 나옵니다. DISTINCT와 GROUP BY는 같은 목록을 내지만, 개수를 셀 때(넷째 줄)는 GROUP BY + COUNT가 필요합니다.
📌 주요 개념 정리
(1) 1:N 조인은 "1" 쪽 행을 늘린다
차 1대 : 대여 기록 N건을 조인하면 그 차가 N줄이 된다. 조인 뒤 "1" 쪽 컬럼만 뽑으면 중복이 생긴다. → 🗄️ 24. JOIN(2) DISTINCT vs GROUP BY
목록에서 중복만 없애면DISTINCT, 묶은 뒤 개수·합계 같은 집계가 필요하면 GROUP BY. GROUP BY H.CAR_ID로도 같은 목록이 나오지만 의도가 덜 드러난다. → 🗄️ 18. GROUP BY(3) EXISTS로 중복 자체를 만들지 않기
"기록이 있는 차"는 조인 대신 WHERE EXISTS (SELECT 1 FROM 대여기록 H WHERE H.CAR_ID = C.CAR_ID AND …)로도 쓸 수 있다. 차 표에서 한 번씩만 고르므로 처음부터 중복이 생기지 않는다.
(4) 날짜 함수 MONTH()·YEAR()·DATE_FORMAT()
날짜에서 월·연을 꺼내거나 원하는 모양의 문자열로 바꾼다. → 🗄️ 27. 내장 함수
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
조인·조건·정렬이 다 맞았는데 왜 같은 CAR_ID가 여러 번 나왔을까?
자동차와 대여 기록이 1:N 관계이기 때문입니다. 한 차가 10월에 세 번 빌려졌다면 조인 결과에 그 차가 세 줄로 나오고, CAR_ID만 뽑으면 같은 값이 세 번 찍힙니다. "중복 없이"라는 요구는 DISTINCT로 맞춥니다.
9월 28일에 빌려서 10월 3일에 반납한 기록은 결과에 들어갈까?
들어가지 않습니다. 조건이 MONTH(H.START_DATE) = 10, 즉 대여 시작일 기준이기 때문입니다. 문제도 "10월에 대여를 시작한 기록"을 묻고 있죠. 날짜 조건은 어느 날짜 컬럼을 기준으로 하는지부터 확인해야 합니다.
💼 이 유형을 다시 만나면문제에 "중복 없이"라는 말이 보이면, 조인한 두 표가 1:N인지부터 확인하세요. 실무에서도 "주문한 적 있는 회원 목록"을 회원 × 주문 조인으로 뽑다가 회원이 주문 수만큼 중복으로 나오는 일이 흔합니다. 결과 건수가 예상보다 많으면 가장 먼저 1:N 조인을 의심하고, DISTINCT나 EXISTS로 정리합니다. 참고로 MONTH(컬럼)처럼 컬럼을 함수로 감싸면 그 컬럼의 인덱스를 쓰지 못한다는 점도 함께 기억해 두세요. → 🗄️ 43. 인덱스가 안 쓰이는 경우
정리 작업 메모
원문 결과는 FAIL. 원문의 제목은 '대여 기록 존재하는 자동차'로 축약되어 있어 공식 문제명을 썼다.
제출 코드·정답 풀이는 Back_end/coding-tests/2026-09-30/rented-sedans-submitted.sql, rented-sedans-answer.sql에 보존. 둘의 차이는 DISTINCT 한 단어.
로컬 검증(프로그래머스 재채점 아님) — verify-sql20.mjs가 SQL 파일을 그대로 읽어 Node 내장 SQLite(메모리 DB)에서 실행, 7개 항목 통과. SQLite에 없는 MONTH()는 같은 이름의 함수를 등록해 제출 SQL을 바꾸지 않았다. 로컬 DB 접속 정보는 쓰지 않았다.
21회차
문자열을 정수로 바꾸기
JAVAparseInt형 변환NumberFormatException
한 줄 요약Integer.parseInt는 맨 앞의 부호(+, -)까지 읽어 int로 바꿔 준다. 숫자가 아닌 글자가 섞이거나 int 범위를 넘으면 NumberFormatException이 난다.
쉽게 말하면종이에 적힌 "-1234"라는 글자를 계산기에 넣을 수 있는 숫자 -1234로 옮겨 적는 일이다. 종이에 "12a4"처럼 숫자가 아닌 게 적혀 있으면 계산기가 거부한다.
class Solution {
public int solution(String s) {
int answer = 0;
answer=Integer.parseInt(s); // ← 부호까지 읽어 int로
return answer;
}
}
💡 풀이 흐름
통과한 코드다. 자바가 제공하는 변환 메서드 한 줄로 끝났다.
1. Integer.parseInt(s)는 문자열을 int로 바꾼다.
2. 부호를 따로 처리할 필요가 없다 — "+1234" → 1234, "-1234" → -1234. 맨 앞의 +·-를 parseInt가 알아서 읽는다.
3. 입력은 최대 5글자라 int 범위를 넘을 일이 없다.
[더 짧게]
return Integer.parseInt(s);
answer를 0으로 만들었다가 다시 덮어쓰는 단계는 없어도 된다. 바로 반환해도 결과는 같다.
숫자로 읽을 수 없는 글자가 하나라도 있으면 예외가 납니다. 이 문제는 입력이 항상 올바르다고 약속했기 때문에 예외 처리가 필요 없었습니다.
📌 주요 개념 정리
(1) 문자열 → 숫자Integer.parseInt(s)(int), Long.parseLong(s)(long), Double.parseDouble(s)(double). int 범위(약 ±21억)를 넘는 수는 Long.parseLong을 쓴다.
(2) 숫자 → 문자열
반대 방향은 String.valueOf(n) 또는 Integer.toString(n). "" + n도 되지만 의도가 덜 드러난다. → ☕ 06. 형 변환(3) NumberFormatException
숫자가 아닌 글자가 섞였거나, 빈 문자열이거나, 범위를 넘으면 난다. RuntimeException 계열이라 컴파일러가 처리를 강제하지 않는다. 사용자 입력처럼 믿을 수 없는 값이면 try-catch로 감싼다. → 🛠️ 05. 예외 처리(4) 캐스팅으로는 안 된다(int) "1234"는 컴파일 에러다. 캐스팅은 숫자 타입끼리(또는 상속 관계의 참조 타입끼리)만 되고, 문자열 → 숫자는 파싱 메서드가 필요하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
"-1234"를 바꿀 때 부호를 떼어 따로 처리해야 할까?
필요 없습니다.Integer.parseInt는 맨 앞의 +·-를 부호로 읽어 줍니다. 부호를 직접 떼고 -1을 곱하는 코드는 이 메서드를 모를 때 쓰는 방법입니다.
(int) "1234" 로 형 변환하면 안 되는 이유는?
String은 숫자 타입이 아니라 참조 타입(객체)이고, int와 상속 관계도 아니기 때문입니다. 캐스팅은 "같은 계열 안에서 모양을 바꾸는 것"이라 문자열의 글자를 읽어 숫자로 계산해 주지 않습니다. 그 일은 parseInt가 합니다.
💼 이 유형을 다시 만나면웹 개발에서 요청 파라미터는 전부 문자열입니다. 게시판의 페이지 번호·글 번호를 request.getParameter("pnum")로 받으면 "3"이라는 String이라 Integer.parseInt로 바꿔 써야 하죠. 이때 사용자가 주소창에 pnum=abc를 넣으면 NumberFormatException이 나므로, 실무에서는 변환 실패 시 기본값(예: 1페이지)으로 처리하는 코드를 함께 둡니다. 코딩테스트에서는 문자열 ↔ 숫자 변환이 다른 문제의 한 단계로 자주 들어갑니다(숫자 문자열과 영단어, 정수 내림차순으로 배치하기).
정리 작업 메모
원문 결과는 PASS(오답노트 원문 기록). 제출 코드는 Back_end/coding-tests/2026-10-02/StringToInt.java에 보존(클래스 이름만 파일 이름에 맞춤).
로컬 검증(프로그래머스 재채점 아님) — 문제 조건(길이 1~5, 부호 가능, 0으로 시작하지 않음)에서 가능한 입력 119,998개(-9999 ~ 99999, "+1234" 꼴 포함) 전부 통과. "12a4"의 예외 메시지도 로컬에서 확인.
21회차
모의고사
JAVA나머지 연산완전 탐색ArrayList
한 줄 요약찍는 패턴은 i % 패턴길이로 돌려 쓰고, 한 번의 반복으로 세 사람을 같이 채점한 뒤, 최고점과 같은 사람을 독립된 if 세 개로 번호 순으로 모은다.
쉽게 말하면세 친구가 정해진 순서로 찍기를 반복한다. 답안지를 한 번 훑으며 셋을 동시에 채점하고, 1등이 여럿이면 모두 상을 준다.
수포자 세 명이 각자 정해진 패턴(1번: 1,2,3,4,5 반복 / 2번: 2,1,2,3,2,4,2,5 반복 / 3번: 3,3,1,1,2,2,4,4,5,5 반복)으로 답을 찍는다. 정답 배열 answers(최대 10,000문제)가 주어질 때 가장 많이 맞힌 사람의 번호를 배열로 반환하는 문제. 여럿이면 오름차순으로 모두 담는다.
import java.util.ArrayList;
class Solution {
public int[] solution(int[] answers) {
int[] p1 = {1, 2, 3, 4, 5};
int[] p2 = {2, 1, 2, 3, 2, 4, 2, 5};
int[] p3 = {3, 3, 1, 1, 2, 2, 4, 4, 5, 5};
int score1 = 0, score2 = 0, score3 = 0;
for (int i = 0; i < answers.length; i++) {
if (answers[i] == p1[i % p1.length]) score1++; // ← 패턴이 끝나면 0번으로 돌아간다
if (answers[i] == p2[i % p2.length]) score2++;
if (answers[i] == p3[i % p3.length]) score3++;
}
int maxScore = Math.max(score1, Math.max(score2, score3));
ArrayList<Integer> list = new ArrayList<>();
if (score1 == maxScore) list.add(1); // ← else if 가 아니라 if 세 개 — 동점자를 모두 담는다
if (score2 == maxScore) list.add(2);
if (score3 == maxScore) list.add(3);
int[] result = new int[list.size()]; // ← 크기를 안 뒤에 배열로 옮긴다
for (int i = 0; i < list.size(); i++) result[i] = list.get(i);
return result;
}
}
💡 정답 풀이
제출 코드 칸이 비어 있어 실제로 어디서 틀렸는지는 알 수 없다. 아래는 이 문제에서 흔히 틀리는 곳과 정답 풀이의 흐름이다.
흔히 틀리는 곳
1. 동점자 처리를 else if로 — 첫 번째 최고점자만 담기고 나머지 동점자가 빠진다. 공식 예제 [1,3,2,4,2]는 세 명 모두 2문제씩 맞혀 답이 [1, 2, 3]인데, else if로 고르면 [1]이 된다.
2. 패턴 인덱스를 그냥 i로 — 문제가 패턴 길이(5·8·10)보다 많으면 ArrayIndexOutOfBoundsException. i % 길이로 돌려야 한다.
3. 점수를 정렬해서 최고점 찾기 — 정렬하면 누가 몇 점이었는지 사라진다. 최고점 값만 Math.max로 구한다.
4. 결과 배열 크기를 미리 3으로 — 최고점자가 1명이면 뒤가 0으로 채워져 틀린다.
풀이 흐름
1. 세 사람의 찍기 패턴을 배열로 만든다.
2. 반복문 하나로 문제를 훑으며 세 사람을 동시에 채점한다(문제 수 N만큼만 돈다, O(N)).
3. Math.max를 두 번 써서 최고점을 구한다.
4. 1번 → 2번 → 3번 순으로 독립된 if를 검사해 최고점자를 ArrayList에 담는다. 검사 순서가 번호 순이라 오름차순이 저절로 맞는다.
5. 리스트를 int[]로 옮겨 반환한다.
실행 결과동점 예제 [1,3,2,4,2] 로 if와 else if 비교 먼저 예측 → 펼쳐서 확인
점수 → 1번 2점, 2번 2점, 3번 2점
정답 풀이 (독립된 if 세 개) → [1, 2, 3]
else if 로 고르면 → [1]
else if는 앞 조건이 참이면 뒤를 아예 검사하지 않습니다. "하나만 고른다"가 아니라 "해당하는 사람 모두"를 고를 때는 조건을 서로 독립시켜야 합니다.
📌 주요 개념 정리
(1) 반복 패턴은 배열[i % 길이]i % 5는 0, 1, 2, 3, 4, 0, 1, …로 돈다. 인덱스가 아무리 커져도 0 ~ 길이−1 안에 머문다. 반복되는 패턴 문제의 기본 도구다.
(2) 독립된 if vs else ifelse if는 여러 갈래 중 하나만 실행한다. 조건을 만족하는 것을 모두 처리하려면 if를 따로따로 쓴다. → ☕ 08. 조건문(3) 크기를 모르는 결과는 ArrayList에 모은 뒤 int[]로
배열은 만들 때 크기가 정해진다. 몇 명이 담길지 모르면 리스트에 담고, 다 담은 뒤 list.size()로 배열을 만들어 옮긴다. → 🛠️ 02. List 계열(4) 정렬 대신 최댓값만
필요한 것이 "가장 큰 값"뿐이면 정렬할 필요가 없다. Math.max(a, Math.max(b, c))는 세 값 중 최댓값이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
2번 수포자가 13번째 문제(i = 12)에서 찍는 답은 무엇이고, 어떻게 구할까?
패턴 길이가 8이므로 12 % 8 = 4, 즉 p2[4]인 2입니다. 패턴 {2,1,2,3,2,4,2,5}를 한 바퀴 돈 뒤 다섯 번째 값이죠. i % 길이가 있으면 패턴을 문제 수만큼 늘려 둘 필요가 없습니다.
최고점자를 고를 때 else if를 쓰면 왜 틀릴까?
else if는 앞 조건이 참이면 뒤 조건을 검사하지 않기 때문입니다. 1번이 최고점이면 2번·3번이 같은 점수여도 담기지 않아요. 동점자를 모두 담으려면 독립된 if 세 개가 필요합니다.
정답을 오름차순으로 정렬하는 코드가 없는데 왜 오름차순이 맞을까?
1번 → 2번 → 3번 순서로 검사하며 담기 때문입니다. 담는 순서 자체가 번호 순이라 따로 정렬할 필요가 없습니다.
💼 이 유형을 다시 만나면i % 길이로 순환하기는 요일 계산, 원형 큐, 순서대로 돌아가며 배정하기(라운드 로빈)처럼 "끝나면 처음으로"가 필요한 곳에 다 쓰입니다. "해당하는 것 모두"를 고를 때 else if를 쓰는 실수는 실무 코드 리뷰에서도 자주 지적되는데, 예를 들어 할인 조건이 여러 개 동시에 적용돼야 하는데 else if로 묶어 하나만 적용되는 버그가 같은 모양입니다.
정리 작업 메모
원문 결과는 FAIL. 오답노트의 제출 코드 칸이 비어 있어 실패 원인을 단정하지 않았다. "흔히 틀리는 곳"은 이 문제의 일반적인 함정이다.
정답 풀이는 Back_end/coding-tests/2026-10-02/MockExam.java(클래스 이름만 바꿔 옮김). 카드의 코드는 클래스 이름을 Solution으로 두고 주석만 줄였다.
로컬 검증(프로그래머스 재채점 아님) — 공식 예제 2개 + 무작위 20,000개(길이 10,000짜리 200개 포함, 동점자 있는 경우 4,931개) 통과. 기댓값은 사람마다 따로 채점하는 별도 코드로 계산. [1,3,2,4,2]의 else if 비교는 로컬에서 확인.
21회차
여러 기준으로 정렬하기
SQLORDER BY다중 정렬
한 줄 요약ORDER BY에 기준을 쉼표로 이어 쓰면 앞 기준이 같을 때만 다음 기준을 본다. ASC·DESC는 기준마다 따로 정한다.
쉽게 말하면이름순으로 줄을 세우되, 이름이 같은 사람끼리는 늦게 온 사람이 앞에 선다. 두 번째 규칙은 첫 번째 규칙으로 순서가 안 갈릴 때만 쓴다.
SELECT ANIMAL_ID, NAME, DATETIME
FROM ANIMAL_INS
ORDER BY NAME ASC, DATETIME DESC; -- ← 1순위 이름 오름차순, 2순위 날짜 내림차순
💡 풀이 흐름
통과한 코드다. 문제 문장을 그대로 ORDER BY로 옮겼다.
1. "이름 순으로" → 1순위 NAME ASC.
2. "이름이 같으면 보호를 나중에 시작한 동물 먼저" → 2순위 DATETIME DESC. 날짜는 늦을수록 큰 값이라, 나중 것을 먼저 보려면 내림차순이다.
3. 두 기준을 쉼표로 잇는다. DB는 이름으로 먼저 줄을 세우고, 이름이 같은 묶음 안에서만 날짜를 비교한다.
이름(Bella → Lucy)이 먼저 정렬되고, 날짜 내림차순은 같은 이름 안에서만 적용됐습니다. 날짜만 보면 2017년 Lucy가 2013년 Bella보다 늦지만, 1순위인 이름이 다르니 날짜는 비교하지 않습니다.
📌 주요 개념 정리
(1) 다중 정렬 — 앞 기준이 같을 때만 다음 기준ORDER BY A, B는 A로 먼저 세우고, A가 같은 행끼리만 B로 다시 세운다. → 🗄️ 15. ORDER BY(2) 방향은 기준마다 따로ORDER BY NAME, DATETIME DESC에서 DESC는 DATETIME에만 붙는다. NAME은 아무것도 안 적었으니 기본값 ASC다. "맨 끝의 DESC가 전체에 적용된다"는 흔한 오해다.
(3) 날짜 정렬
날짜는 이른 날이 작은 값이다. 최신순 = DESC, 오래된 순 = ASC.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
ORDER BY NAME, DATETIME DESC 에서 NAME은 어느 방향으로 정렬될까?
오름차순입니다. DESC는 바로 앞의 DATETIME에만 붙고, 방향을 적지 않은 NAME은 기본값 ASC가 됩니다. 정렬 방향은 기준마다 따로 정해집니다.
ORDER BY DATETIME DESC, NAME ASC 로 순서를 바꾸면 결과가 어떻게 달라질까?
날짜가 1순위가 되어 최신 동물부터 나오고, 이름은 날짜가 완전히 같을 때만 비교됩니다. 같은 이름끼리 모여 있지 않게 되죠. 기준의 순서가 곧 우선순위입니다.
💼 이 유형을 다시 만나면게시판 목록의 "공지글 먼저, 그다음 최신순"이 바로 다중 정렬입니다(ORDER BY 공지여부 DESC, 작성일 DESC). 실무에서는 정렬 기준이 같은 행이 여럿일 때 순서가 매번 달라질 수 있어, 마지막 기준으로 기본 키(글 번호 등)를 붙여 순서를 고정하는 경우가 많습니다. 페이징할 때 같은 글이 두 페이지에 걸쳐 나오는 문제를 막아 줍니다.
정리 작업 메모
원문 결과는 PASS(오답노트 원문 기록). 제출 코드는 Back_end/coding-tests/2026-10-02/multi-sort.sql에 보존.
로컬 검증(프로그래머스 재채점 아님) — verify-sql21.mjs로 Node 내장 SQLite(메모리 DB)에서 실행. 실행 결과의 A002~A900은 이름이 겹치도록 만든 연습 데이터다. 로컬 DB 접속 정보는 쓰지 않았다.
21회차
조건에 맞는 사용자 정보 조회하기
SQLCONCAT_WSSUBSTRGROUP BY · HAVING
한 줄 요약전화번호 앞 세 자리를 '010'으로 고정하면 다른 번호가 틀어진다. 번호는 TLNO에서 SUBSTR로 잘라 붙이고, 주소는 NULL에 안전한 CONCAT_WS로 이어 붙인다.
쉽게 말하면전화번호부를 옮겨 적으면서 앞자리를 전부 "010"으로 써 버리면, 011을 쓰던 사람 번호가 틀린다. 원래 적힌 그대로 잘라 옮겨야 한다.
중고 거래 게시판 USED_GOODS_BOARD와 회원 USED_GOODS_USER에서 게시물을 3건 이상 등록한 사용자의 ID·닉네임·전체 주소·전화번호를 조회하는 문제. 전체 주소는 시·도로명 주소·상세 주소를 이어 붙이고, 전화번호는 xxx-xxxx-xxxx처럼 하이픈을 넣으며, 회원 ID 내림차순으로 정렬한다.
SELECT DISTINCT U.USER_ID, U.NICKNAME,
CONCAT(U.CITY, ' ', U.STREET_ADDRESS1, ' ', U.STREET_ADDRESS2) AS 전체주소, -- ← NULL 하나면 전체 NULL
CONCAT('010','-',SUBSTR(U.TLNO,4,4),'-',SUBSTR(U.TLNO,8,4)) AS 전화번호 -- ← 앞자리 '010' 고정
FROM USED_GOODS_BOARD G JOIN USED_GOODS_USER U ON G.WRITER_ID = U.USER_ID
WHERE G.WRITER_ID IN (SELECT WRITER_ID from USED_GOODS_BOARD
group by WRITER_ID having count(*) >= 3)
ORDER BY U.USER_ID DESC;
SQL정답 풀이 (오답노트 원문)
SELECT U.USER_ID, U.NICKNAME,
CONCAT_WS(' ', U.CITY, U.STREET_ADDRESS1, U.STREET_ADDRESS2) AS 전체주소, -- ← 구분자 한 번, NULL은 건너뜀
CONCAT(SUBSTR(U.TLNO, 1, 3), '-', SUBSTR(U.TLNO, 4, 4), '-', SUBSTR(U.TLNO, 8, 4)) AS 전화번호
FROM USED_GOODS_USER U
JOIN USED_GOODS_BOARD B ON U.USER_ID = B.WRITER_ID
GROUP BY U.USER_ID
HAVING COUNT(*) >= 3 -- ← 묶은 뒤의 그룹을 거른다
ORDER BY U.USER_ID DESC;
💡 정답 풀이
"글 3개 이상 쓴 사람" 고르기는 맞았다. 차이는 출력 형식이다.
제출 코드(WHERE IN 서브쿼리 + DISTINCT)와 정답 풀이(GROUP BY + HAVING)는 같은 사람들을 고른다. 다른 곳은 두 군데다.
틀린 이유 1 — 전화번호 앞자리를 '010'으로 고정
TLNO가 011로 시작하면 제출 코드는 010-…으로 찍는다. 정답은 SUBSTR(U.TLNO, 1, 3)으로 원래 앞 세 자리를 잘라 쓴다. 실제 채점 데이터를 볼 수 없어 이것이 FAIL의 원인이라고 단정할 수는 없지만, 가장 분명한 차이다.
틀린 이유 2 — 주소를 CONCAT으로 이음
MySQL의 CONCAT은 인자 하나라도 NULL이면 결과 전체가 NULL이다. 상세 주소가 비어 있는 회원은 주소 칸이 통째로 NULL이 된다. CONCAT_WS(구분자, …)는 사이사이에 구분자를 넣고 NULL은 건너뛴다.
풀이 흐름 (정답 풀이)
1. 회원(U)과 게시글(B)을 작성자 ID로 조인한다.
2. GROUP BY U.USER_ID로 회원별로 묶고 HAVING COUNT(*) >= 3으로 글 3개 이상만 남긴다.
3. 주소는 CONCAT_WS(' ', …), 전화번호는 SUBSTR 세 조각을 '-'로 잇는다.
4. ORDER BY U.USER_ID DESC.
참고 — GROUP BY와 SELECT 컬럼
정답 풀이는 GROUP BY U.USER_ID만 쓰고 닉네임·주소를 SELECT한다. USER_ID가 기본 키라 MySQL 5.7 이상은 나머지 컬럼이 USER_ID로 정해진다고 보고 허용한다(함수적 종속). 기본 키가 아닌 컬럼으로 묶을 때는 원문 정리대로 SELECT의 컬럼을 GROUP BY에도 적는다.
실행 결과011 번호와 상세 주소가 NULL인 회원을 넣은 연습 데이터로 먼저 예측 → 펼쳐서 확인
제출 코드: user011 | 엘사 | … | 010-9876-5432 ← 실제 번호는 011-9876-5432
noaddr2 | 널 | NULL | 010-1111-2222 ← 상세 주소 NULL → 주소 전체 NULL
정답 풀이: user011 | 엘사 | … | 011-9876-5432
noaddr2 | 널 | 서울시 종로구 종로 1 | 010-1111-2222
고르는 사람 4명은 두 방식이 같음 · 글 2개인 사람은 둘 다 제외
011 번호와 NULL 상세 주소는 차이를 보이려고 넣은 데이터입니다. "누구를 고르는가"는 두 쿼리가 같고, "어떻게 찍는가"만 다르다는 것을 확인할 수 있습니다.
📌 주요 개념 정리
(1) CONCAT vs CONCAT_WSCONCAT(a, b, c)는 하나라도 NULL이면 NULL. CONCAT_WS(' ', a, b, c)는 첫 인자를 구분자로 사이사이에 넣고 NULL은 건너뛴다. 구분자를 매번 적지 않아도 돼 짧기도 하다. → 🗄️ 04. CONCAT(2) SUBSTR(문자열, 시작, 길이) — SQL은 1부터 센다SUBSTR('01012345678', 1, 3) → '010'. 자바 substring은 0부터 세고 두 번째 인자가 "끝 위치(미포함)"라서 헷갈리기 쉽다. 길이를 생략하면 끝까지. → 🗄️ 27. 내장 함수(3) WHERE vs HAVINGWHERE는 묶기 전의 행을, HAVING은 GROUP BY로 묶은 뒤의 그룹을 거른다. COUNT(*) >= 3 같은 집계 함수 조건은 HAVING에. → 🗄️ 19. HAVING vs WHERE(4) 값을 하드코딩하지 않는다
"대부분 010이니까"는 데이터가 보장해 주지 않는다. 원본 컬럼에서 잘라 쓰면 어떤 번호가 들어와도 맞다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 오답노트는 다시 떠올려 봐야 내 것이 됩니다.
상세 주소(STREET_ADDRESS2)가 NULL인 회원은 두 쿼리에서 각각 어떻게 나올까?
제출 코드의 CONCAT은 인자 중 하나라도 NULL이면 결과 전체가 NULL이라 주소 칸이 통째로 비어 나옵니다. 정답 풀이의 CONCAT_WS는 NULL을 건너뛰고 시·도로명 주소만 이어 붙여 보여 줍니다.
TLNO가 '01012345678'일 때 SUBSTR(TLNO, 4, 4)는 무엇일까?
'1234'입니다. SQL은 1번째 글자부터 세므로 4번째 글자 '1'부터 4글자를 자릅니다. 앞 세 자리는 SUBSTR(TLNO, 1, 3) = '010', 마지막 네 자리는 SUBSTR(TLNO, 8, 4) = '5678'이죠.
COUNT(*) >= 3 조건을 WHERE에 쓰면 왜 안 될까?
WHERE는 묶기 전, 행 하나하나를 거르는 단계라 아직 "회원별 글 수"가 계산되지 않았기 때문입니다. 집계 결과로 거르는 조건은 GROUP BY 뒤의 HAVING에 써야 합니다.
💼 이 유형을 다시 만나면"출력 형식을 맞춰라"(하이픈 넣기, 주소 이어 붙이기, 날짜 모양 바꾸기)는 코딩테스트 SQL에서 자주 나오고, 고르는 로직이 맞아도 형식 하나로 FAIL이 납니다. 같은 중고거래 테이블은 조건에 부합하는 중고거래 상태 조회하기에도 나왔습니다. 실무에서도 화면에 보여 줄 전화번호·주소 포맷을 SQL이나 서버에서 만들 때, 선택 입력 칸(상세 주소 등)이 NULL이면 CONCAT 결과가 통째로 사라지는 버그가 자주 생깁니다. NULL일 수 있는 컬럼을 이어 붙일 땐 CONCAT_WS나 IFNULL을 먼저 떠올리세요.
정리 작업 메모
원문 결과는 FAIL. 제출 코드·정답 풀이는 Back_end/coding-tests/2026-10-02/user-info-submitted.sql, user-info-answer.sql에 보존. 카드의 제출 코드는 SELECT 줄만 보기 좋게 나눴다(내용 동일).
로컬 검증(프로그래머스 재채점 아님) — verify-sql21.mjs가 SQL 파일을 그대로 읽어 Node 내장 SQLite(메모리 DB)에서 실행, 8개 항목 통과. SQLite의 CONCAT은 NULL을 빈 문자열로 치므로 MySQL처럼 NULL을 돌려주는 같은 이름의 함수를 등록했다. 로컬 DB 접속 정보는 쓰지 않았다.
🗄️ 6. SQL 기초 — MySQL · HeidiSQL
자바 교안을 마치고 데이터베이스 과정으로 넘어와 정리한 노트입니다. HeidiSQL로 MariaDB(MySQL 호환)에 접속해 scott·hr·employees 실습 DB를 조회하고, 직접 sqlDB를 만들어 데이터를 넣어 보면서 SELECT · WHERE · 연산자 · 테이블 생성 · 서브쿼리에서 시작해 정렬 · 집계 함수 · GROUP BY · JOIN · DML/DDL · 인라인 뷰 · CTE · 내장 함수를 거쳐 윈도우 함수 · 피벗 · 조인 심화 · 제약조건 · 뷰 · 인덱스를 지나 다대다 관계 · OUTER JOIN 실전 · CASCADE · 인덱스 관리를 지나 EXPLAIN 실행 계획 · 스토어드 프로시저와 함수까지 다룹니다. 초기 실습 카드의 실행 결과는 수업 자료를 실제 DB에 그대로 올려 돌려 본 출력이고, 수업 파일에서 발견한 함정 세 가지(정렬되지 않는 ORDER BY · 와일드카드 없는 LIKE · NOT IN의 NULL 함정)도 함께 검증해 정리했습니다. 원본 실습 스크립트는 sql_edu_project에 있고, 수업 중 작성한 쿼리는 쿼리 #1.sql에서 통째로 볼 수 있습니다.
01
HeidiSQL로 MySQL 접속하고 데이터베이스 둘러보기
HeidiSQLUSESHOW DATABASESSHOW TABLESDESC
한 줄 요약쿼리를 쓰기 전에 어떤 데이터베이스를 쓸지 USE로 고르고, SHOW·DESC로 그 안에 어떤 테이블이 있고 각 테이블이 어떤 컬럼으로 이루어져 있는지 먼저 확인한다. (수업 DB는 MariaDB(MySQL 호환)라서, 이 탭의 "MySQL" 설명과 문법이 그대로 통한다.)
쉽게 말하면SQL은 거대한 서류 창고를 다루는 일이에요. USE는 "오늘은 몇 번 창고에서 일하겠다"고 고르는 것이고, SHOW TABLES는 그 창고 안에 어떤 서랍(테이블)이 있는지 목록을 보는 것, DESC는 서랍 하나를 열어 칸(컬럼)이 어떻게 나뉘어 있는지 확인하는 것입니다. 창고를 안 고르고 서랍부터 찾으면 No database selected 에러가 납니다.
SQLHeidiSQL 쿼리 탭
-- USE 문법: 사용하고자 하는 데이터베이스 선택
USE scott;
SHOW DATABASES; -- 서버에 있는 데이터베이스 전체 목록
SHOW TABLES; -- 현재 선택된 DB의 테이블 목록
DESC DEPT; -- 테이블 하나의 컬럼 구조
SHOW TABLE STATUS; -- 테이블들의 엔진·행수·용량 등 상세 정보
DESC 결과의 Key 열에 붙은 PRI가 기본키(Primary Key)입니다. DEPTNO가 PRI이고 Null이 NO인 것에서 "부서번호는 비어 있을 수 없고 중복될 수 없다"는 규칙을 읽어낼 수 있습니다. 실습 DB는 scott(사원·부서), hr(인사), employees(대용량 샘플), 직접 만든 sqlDB까지 네 개를 씁니다.
01번 정리 — 핵심 정리
USE 디비명; — 이후 쿼리가 적용될 데이터베이스를 고른다. HeidiSQL 왼쪽 트리에서 더블클릭해도 같은 효과.
SHOW DATABASES; 서버의 DB 목록 / SHOW TABLES; 현재 DB의 테이블 목록.
DESC 테이블명; = DESCRIBE. 컬럼명·자료형·NULL 허용 여부·키·기본값을 한 번에 보여준다.
SQL 키워드는 대소문자를 가리지 않지만, 키워드는 대문자 · 테이블/컬럼명은 소문자로 쓰는 습관이 읽기 좋다.
문장 끝의 세미콜론(;)이 한 문장의 끝을 알린다. HeidiSQL에서 여러 문장을 한 번에 실행할 때 반드시 필요.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
USE를 안 쓰고 바로 SELECT를 하면 무슨 일이 생기고, 왜 그런가?
No database selected 에러가 납니다. 서버 하나에 데이터베이스가 여러 개 있고 같은 이름의 테이블이 여러 DB에 존재할 수 있어서, 어느 창고를 뒤질지 정하지 않으면 DB가 판단할 근거가 없습니다. scott.emp처럼 DB명을 붙여 쓰면 USE 없이도 조회됩니다.
DESC 결과에서 이 컬럼이 기본키인지 어떻게 알 수 있나?
Key 열에 PRI가 붙어 있으면 기본키입니다. 이때 Null 열은 자동으로 NO가 되는데, 기본키는 중복 불가 + NULL 불가가 함께 걸리기 때문입니다. 둘이 따로 노는 게 아니라 PK 하나가 두 규칙을 동시에 겁니다.
💼 실무·코딩테스트에서는실무에서 낯선 데이터베이스를 처음 받으면 가장 먼저 하는 일이 이 세 명령입니다. SHOW TABLES로 테이블 목록을 훑고, 관심 있는 테이블에 DESC를 쳐서 컬럼과 키를 파악한 뒤에야 쿼리를 짭니다. 남이 만든 스키마를 읽는 능력이 실무 SQL의 절반입니다.
02
SELECT ~ FROM — 표에서 원하는 열만 꺼내기
SELECTFROM*LIMIT
한 줄 요약SELECT 꺼낼컬럼 FROM 테이블이 SQL의 가장 기본 뼈대다. *는 모든 컬럼을 뜻하고, 컬럼명을 쉼표로 나열하면 원하는 것만 원하는 순서로 뽑을 수 있다.
쉽게 말하면엑셀 표에서 필요한 열만 골라 새 표를 만드는 것과 같아요. FROM이 "어느 표에서"이고 SELECT가 "어느 열을"입니다. 읽는 순서도 FROM 먼저, SELECT 나중이라고 생각하면 이해가 쉽습니다 — 표를 먼저 정하고, 거기서 열을 고르는 거니까요.
SQLscott 실습 Q1 · Q2
-- Q1) 사원 테이블(EMP)의 모든 데이터를 출력하자.
SELECT * FROM EMP;
-- Q2) 사원의 이름(ENAME), 사원번호(EMPNO), 월급(SAL)을 출력하자.
SELECT ENAME, EMPNO, SAL
FROM EMP;
-- 행이 너무 많을 때는 LIMIT으로 잘라서 확인한다
SELECT * FROM EMP LIMIT 20;
실행 결과Q2 실행 결과 (앞 5행) 먼저 예측 → 펼쳐서 확인
+--------+-------+------+
| ENAME | EMPNO | SAL |
+--------+-------+------+
| SMITH | 7369 | 800 |
| ALLEN | 7499 | 1600 |
| WARD | 7521 | 1250 |
| JONES | 7566 | 2975 |
| MARTIN | 7654 | 1250 |
+--------+-------+------+
SELECT에 적은 순서 그대로 열이 나옵니다 — 테이블에 저장된 순서(EMPNO가 먼저)와 달라도 됩니다. 즉 SELECT는 "보여줄 모양"을 정하는 것이지 원본 테이블을 바꾸는 게 아닙니다.
02번 정리 — 핵심 정리
SELECT * FROM 테이블; — *는 모든 컬럼. 확인용으로는 편하지만 실무에서는 필요한 컬럼만 적는 게 원칙이다(불필요한 데이터 전송을 줄임).
컬럼은 쉼표로 구분하고, 적은 순서대로 출력된다.
LIMIT n으로 앞에서 n행만 볼 수 있다 — employees 샘플 DB의 사원 테이블처럼 행이 약 30만 개(300,024)인 테이블에서는 필수.
줄바꿈은 자유롭다. SELECT / FROM / WHERE를 줄마다 나눠 쓰면 길어질수록 읽기 쉽다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
SELECT와 FROM 중 무엇이 먼저 실행되며, 그게 왜 중요한가?
FROM이 먼저입니다. 표를 정한 다음에 그 표에서 열을 고르는 순서죠. 이 순서 때문에 SELECT에서 만든 별칭을 WHERE에서 쓸 수 없습니다(별칭 = AS로 컬럼에 붙이는 새 이름, 03번 카드 / WHERE = 조건에 맞는 행만 거르는 절, 05번 카드) — WHERE가 실행될 때는 아직 SELECT가 실행 전이라 별칭이 존재하지 않습니다.
실무에서 SELECT * 를 피하라고 하는 이유를 두 가지만 대보세요.
① 불필요한 데이터 전송 — 안 쓸 컬럼까지 네트워크로 가져오면 느려집니다. ② 테이블 구조 변경에 취약 — 나중에 컬럼이 추가·삭제되면 코드가 조용히 깨지거나 엉뚱한 값을 받습니다. 확인용으로는 편하지만 코드에 남길 때는 컬럼을 명시합니다.
💼 실무·코딩테스트에서는코딩테스트 SQL 문제는 출력 컬럼과 순서를 정확히 맞춰야 통과합니다. SELECT *로 풀면 대부분 실패해요. 문제에 적힌 컬럼명·순서·별칭을 그대로 옮겨 적는 습관을 들이세요.
03
산술 연산과 별칭(AS) — 월급으로 연봉 계산하기
산술연산AS별칭alias
한 줄 요약SELECT 절에서는 컬럼끼리 + - * / 연산을 할 수 있고, 그렇게 만든 계산 결과 컬럼은 AS 별칭으로 이름을 붙여 줘야 결과 표의 머리글이 알아보기 쉬워진다.
쉽게 말하면표에 없던 열을 즉석에서 만들어 내는 것이에요. 월급(SAL) 열만 있고 연봉 열은 없지만, SAL*12라고 쓰면 그 자리에서 연봉 열이 생깁니다. 다만 이름표를 안 붙이면 머리글이 SAL*12라는 수식 그대로 나와서 보기 안 좋으니, AS sal_year로 별명을 달아 주는 겁니다. 원본 테이블은 전혀 바뀌지 않습니다.
SQLscott 실습 Q3
-- Q3) 사원의 이름과 연봉을 출력하자.
SELECT ENAME, SAL*12 AS sal_year
FROM EMP;
실행 결과실행 결과 (앞 4행) 먼저 예측 → 펼쳐서 확인
+-------+----------+
| ENAME | sal_year |
+-------+----------+
| SMITH | 9600 |
| ALLEN | 19200 |
| WARD | 15000 |
| JONES | 35700 |
+-------+----------+
AS를 빼고 SAL*12 sal_year처럼 공백만 두어도 동작하지만, AS를 적는 편이 읽기에 명확합니다. 별칭에 공백이나 한글을 넣고 싶으면 백틱으로 감쌉니다.
03번 정리 — 핵심 정리
SELECT 절의 산술 연산: + - * /, 나머지는 % 또는 MOD().
AS 별칭은 결과 표의 머리글만 바꾼다 — 원본 테이블의 컬럼명은 그대로다.
별칭에 공백·한글·예약어를 쓰려면 백틱으로 감싼다.
주의 — 계산에 쓰인 컬럼에 NULL이 하나라도 있으면 결과도 NULL이 된다. SAL*12+COMM 같은 식은 COMM이 NULL인 사원에서 전부 NULL이 되므로 IFNULL(COMM,0)으로 감싸야 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
SAL*12 AS sal_year 를 쓰면 원본 테이블의 SAL 값도 바뀌나?
바뀌지 않습니다.SELECT는 보여줄 모양을 만드는 것일 뿐 저장된 데이터를 건드리지 않습니다. 원본을 바꾸는 건 UPDATE뿐입니다. 이 구분이 안 되면 조회 쿼리를 무서워하게 되는데, SELECT는 아무리 돌려도 안전합니다.
SAL*12 + COMM 으로 실수령 연봉을 구했더니 절반이 NULL로 나왔다. 왜인가?
NULL이 섞인 산술 연산의 결과는 전부 NULL이기 때문입니다. 커미션이 없는 사원은 COMM이 NULL이고, 숫자 + NULL = NULL이 됩니다. SAL*12 + IFNULL(COMM, 0)으로 감싸야 합니다. NULL은 0이 아니라 '모름'이라 계산에 전염됩니다.
💼 실무·코딩테스트에서는실무 리포트 쿼리에서 가장 흔한 버그가 이 NULL 전염입니다. 금액 컬럼을 더할 때는 습관적으로 IFNULL(컬럼, 0)을 감싸는 팀이 많습니다. 별칭에 한글·공백을 쓰려면 백틱으로 감싸는 것도 실무에서 자주 씁니다.
04
CONCAT — 여러 컬럼을 문장 하나로 합치기
CONCAT문자열ORDER BY 함정
한 줄 요약CONCAT(a, b, c, ...)는 인자로 준 값들을 순서대로 이어 붙여 하나의 문자열 컬럼으로 만든다. 컬럼과 고정 문구를 섞어 보고서 형태의 한 줄 문장을 만들 때 쓴다.
쉽게 말하면여러 칸에 나뉘어 있던 정보를 한 문장으로 엮어 주는 풀이에요. 이름 칸, 입사일 칸, 월급 칸을 따로 보는 대신 "SMITH님이 1980-12-17에 입사를 하고 800의 월급을 받습니다."라는 읽을 수 있는 문장 하나로 만듭니다. 자바의 문자열 + 연결과 같은 일을 하는데, SQL에서는 +가 아니라 CONCAT() 함수를 씁니다.
SQLscott 실습 Q9
-- Q9) 사원 테이블의 모든 데이터를
-- "OO님이 0000-00-00에 입사를 하고 OO의 월급을 받습니다." 형식의 한 컬럼으로 출력하자.
SELECT CONCAT(ENAME, '님이 ', HIREDATE, '에 입사를 하고 ', SAL, '의 월급을 받습니다.') AS 사원정보
FROM EMP;
수업 파일에 있던 ORDER BY '급여지급현황'은 실제로는 정렬을 하지 않습니다. 직접 확인해 본 결과, 이 줄이 있을 때와 없을 때의 출력 순서가 완전히 같았습니다. 따옴표로 감싼 '급여지급현황'은 컬럼 이름이 아니라 그냥 문자열 상수라서, 모든 행이 같은 값을 갖게 되어 정렬 기준이 되지 못하기 때문입니다. 컬럼으로 정렬하려면 따옴표 없이 ORDER BY ENAME처럼 쓰거나, 별칭을 쓸 때는 ORDER BY 사원정보라고 적어야 합니다.
04번 정리 — 핵심 정리
CONCAT(값1, 값2, ...) — 인자를 순서대로 이어 붙인다. 숫자·날짜도 자동으로 문자열로 바뀐다.
인자 중 하나라도 NULL이면 결과 전체가 NULL이 된다. NULL이 섞일 수 있으면 IFNULL(COMM,0)으로 감싸거나, NULL을 건너뛰는 CONCAT_WS(구분자, ...)를 쓴다.
따옴표로 감싼 이름은 컬럼이 아니라 문자열 상수다 — ORDER BY '컬럼명'은 정렬되지 않으니 주의.
MySQL에서 문자열 연결에 +를 쓰면 숫자로 변환을 시도해 0이 나올 수 있다. 반드시 CONCAT을 쓸 것.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
ORDER BY '급여지급현황' 이 정렬을 하지 않는 이유를 설명해보세요.
따옴표로 감싼 것은 컬럼 이름이 아니라 문자열 상수이기 때문입니다. 모든 행이 '급여지급현황'이라는 똑같은 값을 갖게 되니 정렬 기준이 되지 못하고, 원래 순서가 그대로 유지됩니다. 에러도 안 나서 더 위험합니다.
CONCAT으로 만든 문장에 NULL이 섞이면?
결과 전체가 NULL이 됩니다. 이름·날짜가 멀쩡해도 급여 하나가 NULL이면 그 행의 문장이 통째로 사라져요. NULL을 건너뛰고 이어 붙이려면 CONCAT_WS(구분자, ...)를 쓰거나, 각 인자를 IFNULL로 감쌉니다.
💼 실무·코딩테스트에서는코딩테스트에서 '~km', '~원' 같은 단위를 붙여 출력하라는 조건이 자주 나옵니다(코딩테스트 카드 노선별 평균 역 사이 거리 조회하기가 그런 문제입니다). 이때 CONCAT을 거치면 결과가 문자열이 되어 그 별칭으로 정렬하면 순서가 망가집니다 — ORDER BY에는 가공 전 숫자 식을 쓰세요.
05
WHERE — 조건에 맞는 행만 걸러내기
WHERE비교연산자날짜조건
한 줄 요약WHERE 조건식은 테이블의 모든 행을 하나씩 검사해 조건이 참인 행만 남긴다. SELECT가 "어느 열"을 고른다면 WHERE는 "어느 행"을 고른다.
쉽게 말하면SELECT가 표의 세로(열)를 골랐다면, WHERE는 가로(행)를 고릅니다. 창고에서 서류를 꺼낼 때 "월급이 2000 이하인 사람 것만" 같은 조건을 붙여 추려내는 것이죠. 조건에 맞는 행이 하나도 없으면 결과는 에러가 아니라 빈 표가 나옵니다.
SQLscott 실습 Q1 · Q3 · Q5
-- Q1) 사원번호가 '7844'인 사원의 사원번호, 이름, 월급
SELECT EMPNO, ENAME, SAL
FROM emp
WHERE EMPNO = '7844';
-- Q3) 입사일이 1980년 12월 17일인 사원의 모든 데이터
SELECT *
FROM emp
WHERE HIREDATE = '1980-12-17';
-- Q5) 월급이 2000 이하인 사원의 이름과 월급
SELECT ENAME, SAL
FROM emp
WHERE SAL <= 2000;
EMPNO는 숫자 컬럼인데 '7844'처럼 따옴표를 붙여도 동작합니다 — MySQL이 상수 '7844'를 숫자로 자동 변환해 주기 때문입니다. 이 방향(숫자 컬럼 = '문자 상수')은 상수 쪽만 바뀌므로 인덱스도 그대로 쓸 수 있습니다. 문제가 되는 건 반대 방향 — 문자열 컬럼을 숫자와 비교(WHERE 문자컬럼 = 7844)하면 행마다 컬럼 값을 숫자로 바꿔 비교해야 해서 인덱스를 못 씁니다. 헷갈리지 않게 컬럼 타입에 맞춰 숫자 컬럼에는 WHERE EMPNO = 7844, 문자 컬럼에는 따옴표를 쓰는 습관이 좋습니다. 반대로 날짜는 반드시 따옴표로 감싸야 합니다.
05번 정리 — 핵심 정리
비교 연산자: =, >, <, >=, <=, 같지 않음은 <> 또는 !=.
자바의 ==와 달리 SQL에서 같음 비교는 등호 하나(=)다.
문자열과 날짜는 작은따옴표로 감싼다: 'SMITH', '1980-12-17'. 날짜 형식은 YYYY-MM-DD.
NULL은 = NULL로 비교할 수 없다 — 반드시 IS NULL / IS NOT NULL을 쓴다.
실행 순서는 FROM → WHERE → SELECT다. 그래서 SELECT에서 만든 별칭은 WHERE에서 쓸 수 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
SELECT는 무엇을 고르고 WHERE는 무엇을 고르는가?
SELECT는 세로(열), WHERE는 가로(행)를 고릅니다. 표에서 필요한 칸만 남기는 두 방향의 가위라고 보면 정확합니다. 둘은 서로 독립적이라 어느 쪽을 먼저 생각하든 상관없지만, 실행은 WHERE가 먼저입니다.
WHERE comm = NULL 로 커미션 없는 사원을 찾으면 왜 0행인가?
NULL은 '값을 모른다'는 상태라 어떤 값과 비교해도 참이 되지 않습니다. NULL = NULL조차 참이 아니라 UNKNOWN입니다. 그래서 NULL 전용 문법인 IS NULL / IS NOT NULL이 따로 존재합니다.
💼 실무·코딩테스트에서는실무에서 UPDATE·DELETE를 하기 전에 같은 WHERE로 SELECT를 먼저 돌려 보는 것이 철칙입니다. 지우려는 행이 정확히 그것들인지 눈으로 확인한 뒤, SELECT 자리만 DELETE로 바꿉니다. 이 습관 하나가 사고를 막습니다.
06
AND · OR — 조건 여러 개 묶기
ANDOR우선순위
한 줄 요약AND는 양쪽 조건이 모두 참일 때, OR는 하나라도 참일 때 그 행을 남긴다. AND가 OR보다 먼저 계산되므로 섞어 쓸 때는 괄호로 묶어야 안전하다.
쉽게 말하면AND는 체를 두 번 거르는 것이라 결과가 점점 줄어들고, OR는 두 바구니를 합치는 것이라 결과가 늘어납니다. 아래 예제에서 같은 두 조건을 AND로 묶으면 2명, OR로 묶으면 7명이 나오는 걸 보면 차이가 바로 보입니다.
SQLsqlDB 실습 — 같은 조건, AND와 OR
-- 1970년 이후 출생 그리고 키 182 이상 (둘 다 만족)
SELECT userid, name
FROM usertbl
WHERE birthyear >= 1970 AND height >= 182;
-- 1970년 이후 출생 또는 키 182 이상 (하나만 만족해도 됨)
SELECT userid, name
FROM usertbl
WHERE birthyear >= 1970 OR height >= 182;
AND의 결과 2명은 OR의 결과 7명에 그대로 포함되어 있습니다. AND는 교집합, OR는 합집합이라고 보면 정확합니다. 참고로 임재범은 키 182라 OR에는 들어왔지만 1963년생이라 AND에서는 빠졌습니다.
06번 정리 — 핵심 정리
AND = 교집합(둘 다 참), OR = 합집합(하나라도 참).
AND가 OR보다 우선순위가 높다 — A OR B AND C는 A OR (B AND C)로 해석된다.
의도를 분명히 하려면 괄호를 직접 붙이는 게 안전하다: (A OR B) AND C.
조건이 3개 이상 이어져도 계속 AND/OR로 연결할 수 있다.
같은 컬럼을 여러 값과 OR로 비교하는 경우라면 IN으로 줄여 쓰는 편이 읽기 좋다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
A OR B AND C 는 어떻게 해석되는가?
A OR (B AND C)입니다. AND가 OR보다 우선순위가 높기 때문입니다. 자바의 &&가 ||보다 우선인 것과 같은 규칙이죠. 의도가 (A OR B) AND C였다면 결과가 완전히 달라집니다 — 그래서 괄호를 직접 붙이는 게 안전합니다.
AND로 조건을 추가하면 결과가 늘어날까 줄어들까?
줄어듭니다. AND는 체를 한 번 더 거르는 것이라 조건이 늘수록 통과하는 행이 적어집니다. 반대로 OR는 바구니를 합치는 것이라 늘어납니다. 결과 행 수가 예상과 반대로 움직이면 AND/OR를 잘못 쓴 것입니다.
💼 실무·코딩테스트에서는실무 쿼리는 조건이 대여섯 개씩 붙는 게 보통이라 괄호를 명시적으로 쓰는 것이 코드 리뷰 관례입니다. 우선순위를 외워서 괄호를 생략하면 다음 사람이(그리고 3개월 뒤의 내가) 잘못 읽습니다.
07
BETWEEN A AND B — 범위로 걸러내기
BETWEEN범위이상이하
한 줄 요약컬럼 BETWEEN 작은값 AND 큰값은 컬럼 >= 작은값 AND 컬럼 <= 큰값과 완전히 같다. 양쪽 끝 값을 포함하는 것이 핵심이다.
쉽게 말하면"180부터 183까지"처럼 구간을 통째로 지정하는 문법이에요. 부등호 두 개를 AND로 잇는 것보다 짧고 읽기 쉽습니다. 가장 자주 실수하는 부분은 양 끝이 포함된다는 점 — "180 초과"가 아니라 "180 이상"입니다. 그리고 작은 값을 먼저 써야 합니다. 순서를 바꾸면 에러 없이 결과가 0행으로 나와서 알아채기 어렵습니다.
SQLsqlDB · scott 실습
-- 키가 180 이상 183 이하인 회원
SELECT NAME, height
FROM usertbl
WHERE height BETWEEN 180 AND 183;
-- 월급이 1000에서 2000 사이인 사원
SELECT ENAME, SAL
FROM emp
WHERE SAL BETWEEN 1000 AND 2000;
-- 1980년도에서 1982년도 사이에 입사한 사원 (날짜에도 그대로 쓸 수 있다)
SELECT ENAME, HIREDATE
FROM emp
WHERE HIREDATE BETWEEN '1980-01-01' AND '1982-12-31';
182는 180과 183 사이에 들어가므로 포함됩니다. 만약 186인 성시경까지 나오길 기대했다면 범위를 잘못 잡은 것입니다. BETWEEN 183 AND 180처럼 큰 값을 먼저 쓰면 에러 없이 0행이 나오니, 결과가 비었을 때 이 부분을 먼저 의심해 보세요.
07번 정리 — 핵심 정리
A BETWEEN x AND y ≡ A >= x AND A <= y — 양 끝 포함.
반드시 작은 값을 먼저 쓴다. 순서가 뒤바뀌면 에러 없이 결과가 0행이 된다.
숫자뿐 아니라 날짜·문자열에도 쓸 수 있다.
제외하려면 NOT BETWEEN.
날짜에 시분초가 붙어 있는 컬럼(DATETIME)이라면 BETWEEN '2026-01-01' AND '2026-12-31'이 12월 31일 00:00:00까지만 잡아 하루가 빠질 수 있다 — 이때는 < '2027-01-01' 형태가 안전하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
BETWEEN 183 AND 180 처럼 큰 값을 먼저 쓰면 어떻게 되나?
에러가 나지 않고 결과가 0행이 됩니다. x >= 183 AND x <= 180으로 풀리는데 이걸 동시에 만족하는 값은 없으니까요. 조용히 틀리는 부류라 결과가 비었을 때 이 부분을 먼저 의심해야 합니다.
날짜 컬럼에 시분초가 있을 때 BETWEEN '2026-01-01' AND '2026-12-31' 의 함정은?
'2026-12-31'은 12월 31일 00:00:00으로 해석되어, 12월 31일 낮에 생긴 데이터가 통째로 빠집니다. 하루를 잃는 거죠. < '2027-01-01' 형태로 쓰면 이 문제가 사라집니다.
💼 실무·코딩테스트에서는이 날짜 함정은 실무에서 월간·연간 집계를 틀리게 만드는 대표 원인입니다. 매출 리포트가 미묘하게 적게 나오면 대부분 여기입니다. 날짜 범위는 '이상 ~ 미만'으로 잡는 습관을 들이면 안전합니다.
08
IN — 목록 중 하나와 일치하는 행 찾기
INNOT INOR 축약
한 줄 요약컬럼 IN (값1, 값2, ...)은 컬럼 값이 괄호 안 목록 중 어느 하나와 같으면 참이다. 같은 컬럼을 여러 값과 OR로 비교하는 긴 조건을 짧게 줄여 준다.
쉽게 말하면OR를 여러 번 쓴 것을 목록 하나로 압축한 문법이에요. addr='경남' OR addr='전남' OR addr='경북'을 addr IN ('경남','전남','경북') 한 줄로 줄일 수 있습니다. 값이 늘어날수록 이득이 커지고, 나중에 배울 서브쿼리와 결합할 때 진가를 발휘합니다 — 괄호 안에 값 목록 대신 SELECT 문을 통째로 넣을 수 있거든요.
SQLsqlDB · scott 실습
-- 주소가 경남, 전남, 경북 중 하나인 회원
SELECT NAME, addr
FROM usertbl
WHERE addr IN ('경남','전남','경북');
-- 위와 완전히 같은 뜻 (OR로 풀어 쓴 것)
SELECT NAME, addr
FROM usertbl
WHERE addr = '경남' OR addr = '전남' OR addr = '경북';
-- 사원번호가 7369, 7499, 7521인 사원
SELECT ENAME, SAL
FROM emp
WHERE EMPNO IN (7369, 7499, 7521);
실행 결과주소 IN 실행 결과 먼저 예측 → 펼쳐서 확인
+-----------+--------+
| NAME | addr |
+-----------+--------+
| 은지원 | 경북 |
| 김범수 | 경남 |
| 김경호 | 전남 |
| 윤종신 | 경남 |
+-----------+--------+
IN은 목록에 있는 값과 하나라도 일치하면 통과입니다. 경남에 해당하는 사람이 2명이면 2명 모두 나옵니다. 뒤집은 NOT IN은 "목록에 없는 것"을 찾는데, 목록 안에 NULL이 섞이면 결과가 통째로 비어 버리는 함정이 있습니다 — 이건 14번 카드에서 자세히 다룹니다.
08번 정리 — 핵심 정리
컬럼 IN (a, b, c) ≡ 컬럼=a OR 컬럼=b OR 컬럼=c.
문자열 목록은 각각 따옴표로 감싸고 쉼표로 구분한다.
괄호 안에 서브쿼리를 넣을 수 있다: WHERE deptno IN (SELECT deptno FROM ...).
NOT IN은 목록에 없는 행을 찾는다 — 단, 목록에 NULL이 있으면 결과가 0행이 되므로 주의(14번 카드).
목록의 값이 아주 많아지면 IN보다 JOIN이 빠른 경우가 많다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
IN을 OR로 풀어 쓰면 어떻게 되며, 언제 IN이 더 나은가?
addr IN ('경남','전남')은 addr='경남' OR addr='전남'과 같습니다. 값이 두세 개면 차이가 없지만 열 개가 넘어가면 IN이 훨씬 읽기 좋고, 무엇보다 괄호 안에 서브쿼리를 통째로 넣을 수 있다는 게 결정적인 차이입니다.
NOT IN 목록에 NULL이 하나 있으면 결과가 어떻게 되나?
무조건 0행이 됩니다. NOT IN은 목록의 모든 값과 달라야 참인데, NULL과의 비교는 UNKNOWN이라 영원히 참이 될 수 없기 때문입니다. 에러가 아니라 빈 결과라서 놓치기 쉽습니다.
💼 실무·코딩테스트에서는실무에서는 NOT IN 대신 NOT EXISTS나 LEFT JOIN ... IS NULL을 선호하는 팀이 많습니다. NULL 함정이 원천적으로 없고 대용량에서 더 빠른 경우가 많기 때문입니다.
09
LIKE와 와일드카드 % _ — 패턴으로 검색하기
LIKE%_패턴검색
한 줄 요약LIKE는 정확히 일치하는 값이 아니라 모양(패턴)이 맞는 값을 찾는다. %는 길이 제한 없는 아무 문자열, _는 정확히 한 글자를 뜻한다.
쉽게 말하면=가 "이름이 정확히 이것"이라면 LIKE는 "이렇게 생긴 것"을 찾는 검색이에요. 밑줄 _는 글자 한 칸짜리 빈칸이라 '_종신'은 "아무 글자 한 자 + 종신" = 세 글자 이름만 찾습니다. 퍼센트 %는 몇 글자든 상관없는 빈칸이라 '김%'는 김으로 시작하는 모든 이름을 찾습니다.
SQLsqlDB 실습
-- '_종신' : 앞에 아무 글자 한 자 + '종신' → 세 글자 이름
SELECT NAME, height
FROM usertbl
WHERE NAME LIKE '_종신';
-- 참고로 자주 쓰는 패턴들
-- LIKE '김%' : 김으로 시작
-- LIKE '%종%' : 종이 어디든 포함
-- LIKE '%수' : 수로 끝남
수업 파일에는 WHERE ENAME LIKE 'SMITH'처럼 와일드카드가 하나도 없는 LIKE도 있었습니다. 직접 확인해 보니 = 'SMITH'와 결과가 똑같이 1행이었습니다 — 와일드카드가 없으면 LIKE는 그냥 등호와 같은 일을 합니다. 틀린 건 아니지만 이런 경우엔 =를 쓰는 편이 의도가 분명하고 더 빠릅니다.
09번 정리 — 핵심 정리
% = 0글자 이상의 아무 문자열 / _ = 정확히 한 글자.
'_종신'은 세 글자 이름만, '%종신'은 종신으로 끝나는 모든 이름을 찾는다.
와일드카드가 없는 LIKE 'SMITH'는 = 'SMITH'와 동작이 같다 — 이럴 땐 =를 쓰자.
%로 시작하는 패턴('%수')은 인덱스를 쓰지 못해 느리다 — 데이터가 많을수록 주의.
%나 _ 문자 자체를 찾고 싶으면 ESCAPE 절을 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
LIKE '%수' 와 LIKE '수%' 중 어느 쪽이 느리고 왜인가?
'%수'가 느립니다. 인덱스는 값의 앞부분부터 정렬되어 있어서, 앞이 열려 있으면 정렬 순서를 활용할 수 없어 전체를 다 훑어야 합니다. 반대로 '수%'는 '수로 시작하는 구간'만 보면 되니 인덱스가 그대로 쓰입니다.
와일드카드 없이 LIKE 'SMITH' 라고 쓰면?
= 'SMITH'와 완전히 같게 동작합니다. 틀린 건 아니지만 읽는 사람이 '패턴 검색인가?' 하고 한 번 더 생각하게 만들고, 최적화 측면에서도 =가 명확합니다.
💼 실무·코딩테스트에서는검색 기능을 만들 때 LIKE '%키워드%'는 데이터가 많아지면 반드시 느려집니다. 실무에서는 이 지점에서 전문 검색(Full-Text Index)이나 Elasticsearch 같은 검색 엔진으로 넘어갑니다. '왜 LIKE로는 부족한가'를 설명할 수 있으면 면접에서 강합니다.
10
CREATE DATABASE · CREATE TABLE — 표의 설계도 그리기
CREATE자료형PRIMARY KEYFOREIGN KEYAUTO_INCREMENT
한 줄 요약지금까지는 남이 만든 표를 조회만 했다면, CREATE부터는 표를 직접 설계한다. 컬럼마다 자료형을 정하고, PK·FK·NOT NULL 같은 제약조건으로 잘못된 데이터가 애초에 들어오지 못하게 막는다.
쉽게 말하면엑셀로 치면 빈 표의 틀을 짜는 작업이에요. 각 칸에 "여기는 숫자만", "여기는 최대 10글자", "여기는 반드시 채워야 함"이라는 규칙을 미리 박아 두는 것이죠. 이렇게 해 두면 나중에 실수로 이상한 값을 넣으려 할 때 데이터베이스가 알아서 거부합니다. 자바에서 int에 문자열을 못 넣는 것과 같은 안전장치를 표 단위로 거는 셈입니다.
SQLsqldb생성.sql — 회원 테이블과 구매 테이블
CREATE DATABASE sqlDB;
USE sqlDB;
CREATE TABLE userTbl -- 회원 테이블
( userID CHAR(8) NOT NULL PRIMARY KEY, -- 사용자 아이디(PK)
name VARCHAR(10) NOT NULL, -- 이름
birthYear INT NOT NULL, -- 출생년도
addr CHAR(2) NOT NULL, -- 지역(경기,서울,경남 식으로 2글자)
mobile1 CHAR(3), -- 휴대폰 국번(011, 010 등)
mobile2 CHAR(8), -- 나머지 번호(하이픈 제외)
height SMALLINT, -- 키
mDate DATE -- 회원 가입일
);
CREATE TABLE buyTbl -- 회원 구매 테이블
( num INT AUTO_INCREMENT NOT NULL PRIMARY KEY, -- 순번(PK)
userID CHAR(8) NOT NULL, -- 아이디(FK)
prodName CHAR(6) NOT NULL, -- 물품명
groupName CHAR(4), -- 분류
price INT NOT NULL, -- 단가
amount SMALLINT NOT NULL, -- 수량
FOREIGN KEY (userID) REFERENCES userTbl(userID) -- 외래키 지정
);
userTbl을 먼저 만들어야 합니다.buyTbl의 FOREIGN KEY ... REFERENCES userTbl(userID)가 userTbl을 참조하므로, 순서를 바꾸면 errno 150 — Foreign key constraint is incorrectly formed 에러가 납니다(MariaDB 기준 문구, ERROR 1005 ... Can't create table와 함께 나옴. MySQL 8에서는 ERROR 1824 — Failed to open the referenced table으로 나온다). 또 FK로 연결하려면 양쪽 컬럼의 자료형이 정확히 같아야 합니다(둘 다 CHAR(8)).
10번 정리 — 핵심 정리
CHAR(n)은 항상 n칸을 차지하는 고정 길이, VARCHAR(n)은 실제 길이만큼만 쓰는 가변 길이. 길이가 일정한 아이디·국번엔 CHAR, 길이가 들쭉날쭉한 이름엔 VARCHAR가 어울린다.
PRIMARY KEY = 그 행을 구분하는 유일한 값. 중복 불가 + NULL 불가가 자동으로 걸린다.
FOREIGN KEY = 다른 테이블의 PK를 가리키는 컬럼. 존재하지 않는 회원의 구매 기록이 들어가는 것을 막아 준다.
AUTO_INCREMENT = 행을 넣을 때마다 1씩 자동으로 늘어나는 번호. INSERT할 때 그 자리에 NULL을 넣으면 DB가 알아서 채운다.
NOT NULL = 반드시 값이 있어야 하는 컬럼. 생략하면 기본은 NULL 허용이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
CHAR(8)과 VARCHAR(8)의 차이를 저장 방식으로 설명해보세요.
CHAR는 항상 8칸을 차지합니다 — 3글자를 넣어도 나머지를 공백으로 채웁니다. VARCHAR는 실제 길이만큼만 쓰고 길이 정보를 따로 둡니다. 그래서 길이가 일정한 값(아이디·국번·우편번호)엔 CHAR, 들쭉날쭉한 값(이름·주소)엔 VARCHAR가 맞습니다.
PRIMARY KEY를 걸면 자동으로 따라오는 제약이 무엇인가?
중복 불가 + NULL 불가가 동시에 걸리고, 클러스터형 인덱스(그 값 순서대로 데이터를 실제로 정렬해 두는 색인 — 36번 카드)까지 자동으로 생깁니다. 세 가지가 한 번에 붙는 것이죠. UNIQUE는 중복만 막고 NULL은 허용한다는 점에서 다릅니다.
💼 실무·코딩테스트에서는실무 테이블 설계에서 제약조건은 '나중에 추가하자'가 가장 위험합니다. 이미 잘못된 데이터가 쌓인 뒤에는 제약을 걸 수 없어서, 데이터를 정리하는 작업이 먼저 필요해집니다. 설계 단계에서 거는 게 압도적으로 쌉니다.
11
INSERT INTO — 만든 표에 행 채워 넣기
INSERTVALUESN''AUTO_INCREMENT
한 줄 요약INSERT INTO 테이블 VALUES(값들)로 행을 추가한다. 컬럼명을 생략하면 테이블에 정의된 컬럼 순서 그대로 값을 나열해야 하고, AUTO_INCREMENT 컬럼 자리에는 NULL을 넣어 DB가 채우게 한다.
쉽게 말하면설계도(CREATE)로 빈 표를 만들었으니 이제 줄을 하나씩 채워 넣는 단계예요. 값을 나열하는 순서가 표의 칸 순서와 정확히 맞아야 합니다 — 이름 칸에 출생년도를 넣으면 자료형이 안 맞아 에러가 나거나, 더 나쁘게는 엉뚱한 값이 조용히 들어갑니다. 그래서 실무에서는 INSERT INTO userTbl (userID, name) VALUES (...)처럼 컬럼명을 명시하는 방식을 더 많이 씁니다.
SQLsqldb생성.sql — 데이터 넣기 (일부)
-- 회원 (userTbl) : 컬럼 순서대로 8개 값
INSERT INTO userTbl VALUES('LSG', N'이승기', 1987, N'서울', '011', '11111111', 182, '2008-8-8');
INSERT INTO userTbl VALUES('KBS', N'김범수', 1979, N'경남', '011', '22222222', 173, '2012-4-4');
INSERT INTO userTbl VALUES('KKH', N'김경호', 1971, N'전남', '019', '33333333', 177, '2007-7-7');
INSERT INTO userTbl VALUES('JYP', N'조용필', 1950, N'경기', '011', '44444444', 166, '2009-4-4');
INSERT INTO userTbl VALUES('SSK', N'성시경', 1979, N'서울', NULL , NULL , 186, '2013-12-12');
-- … 나머지 5명(LJB, YJS, EJW, JKW, BBK) 생략 — 원본은 총 10명
-- 구매 (buyTbl) : 첫 컬럼 num은 AUTO_INCREMENT라 NULL을 넣으면 자동으로 번호가 매겨진다
INSERT INTO buyTbl VALUES(NULL, 'KBS', N'운동화', NULL , 30, 2);
INSERT INTO buyTbl VALUES(NULL, 'KBS', N'노트북', N'전자', 1000, 1);
INSERT INTO buyTbl VALUES(NULL, 'JYP', N'모니터', N'전자', 200, 1);
-- … 나머지 9건 생략 — 원본은 총 12건 (BBK의 구매도 있으므로 BBK 회원이 먼저 들어가 있어야 한다)
실행 결과적재 후 확인 먼저 예측 → 펼쳐서 확인
SELECT COUNT(*) FROM userTbl; → 10
SELECT COUNT(*) FROM buyTbl; → 12
SELECT * FROM buyTbl LIMIT 3;
+-----+--------+-----------+-----------+-------+--------+
| num | userID | prodName | groupName | price | amount |
+-----+--------+-----------+-----------+-------+--------+
| 1 | KBS | 운동화 | NULL | 30 | 2 |
| 2 | KBS | 노트북 | 전자 | 1000 | 1 |
| 3 | JYP | 모니터 | 전자 | 200 | 1 |
+-----+--------+-----------+-----------+-------+--------+
num에 전부 NULL을 넣었는데 결과에는 1, 2, 3이 자동으로 채워졌습니다 — AUTO_INCREMENT가 동작한 것입니다. 그리고 회원(userTbl)을 먼저 넣어야 합니다. 구매 기록의 userID는 외래키라서, 아직 없는 회원의 구매를 먼저 넣으면 Cannot add or update a child row 에러가 납니다.
11번 정리 — 핵심 정리
INSERT INTO 테이블 VALUES(...) — 컬럼명을 생략하면 정의된 순서대로 전부 넣어야 한다.
INSERT INTO 테이블 (컬럼1, 컬럼2) VALUES (값1, 값2) — 일부 컬럼만 넣을 때. 실무에서 권장되는 형태.
N'문자열'의 N은 유니코드(National) 문자열이라는 표시다. SQL Server에서 한글을 넣을 때 쓰던 표기로, MySQL에서는 붙이지 않아도 한글이 잘 들어간다(교재 호환을 위해 남아 있는 것).
AUTO_INCREMENT 컬럼에는 NULL을 넣으면 DB가 다음 번호를 자동으로 채운다.
외래키가 걸린 테이블은 부모 테이블부터 넣어야 한다(userTbl → buyTbl).
여러 행을 한 번에 넣으려면 VALUES (...), (...), (...)처럼 쉼표로 잇는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
INSERT할 때 컬럼명을 명시하는 방식이 권장되는 이유는?
① 순서를 외울 필요가 없고 ② 일부 컬럼만 넣을 수 있고 ③ 무엇보다 나중에 테이블에 컬럼이 추가돼도 코드가 안 깨집니다. 컬럼명을 생략하면 정의된 순서에 의존하므로, 누군가 컬럼을 추가하는 순간 조용히 엉뚱한 자리에 값이 들어갑니다.
외래키가 걸린 두 테이블에 데이터를 넣는 순서와 그 이유는?
부모(참조되는) 테이블 먼저입니다. 자식의 FK는 '부모에 이 값이 존재한다'를 보장하는 규칙이라, 부모가 없으면 ERROR 1452로 거부됩니다. 회원이 있어야 그 회원의 구매 기록이 존재할 수 있다는 현실의 순서가 그대로 규칙이 된 것입니다.
💼 실무·코딩테스트에서는실무에서 초기 데이터를 넣을 때는 부모 → 자식 순서로 정렬된 스크립트를 만들어 둡니다. 순서가 복잡해지면 SET foreign_key_checks=0으로 잠시 끄기도 하지만, 끄고 넣은 데이터는 무결성이 깨져 있을 수 있어 작업 후 검증이 필수입니다.
12
서브쿼리 (1) — 쿼리 안의 쿼리, 단일행
서브쿼리subquery단일행
한 줄 요약괄호 안에 SELECT를 넣어 먼저 계산한 결과를 바깥 쿼리의 조건 값으로 쓰는 것이 서브쿼리다. 안쪽 결과가 값 하나(1행 1열)일 때는 =로 비교할 수 있다.
쉽게 말하면"CHICAGO에서 일하는 사람과 같은 부서인 사원"을 찾으려면 두 단계가 필요해요. ① CHICAGO의 부서번호가 몇 번인지 알아내고 ② 그 번호인 사원을 찾는 것이죠. 원래는 쿼리를 두 번 실행해서 눈으로 번호를 확인한 뒤 옮겨 적어야 하는데, 그 중간 과정을 괄호 안에 통째로 넣어 한 번에 끝내는 게 서브쿼리입니다. 안쪽이 먼저 실행되고, 그 결과가 바깥 조건의 자리에 값처럼 끼워집니다.
SQLscott 서브쿼리 실습 03 · 06
-- 03. 'CHICAGO'에서 근무하는 사원들과 같은 부서에서 근무하는
-- 사원의 이름과 월급을 출력하자.
SELECT ename, sal
FROM emp
WHERE deptno = (SELECT deptno FROM dept WHERE loc = 'CHICAGO');
-- 06. 'KING'에게 보고하는 사원 (관리자가 KING인 사원)
SELECT ename, sal
FROM emp
WHERE mgr = (SELECT empno FROM emp WHERE ename = 'KING');
실행 결과03번 실행 결과 (앞 2행) 먼저 예측 → 펼쳐서 확인
+--------+------+
| ename | sal |
+--------+------+
| ALLEN | 1600 |
| WARD | 1250 |
+--------+------+
안쪽 SELECT deptno FROM dept WHERE loc='CHICAGO'가 먼저 실행되어 30이라는 값 하나를 내놓고, 바깥 쿼리는 WHERE deptno = 30이 된 것처럼 동작합니다. 중요한 제약 — =를 쓰려면 안쪽 결과가 반드시 1행이어야 합니다. 2행 이상이면 Subquery returns more than 1 row 에러가 나고, 그때는 다음 카드의 IN·ANY·ALL을 써야 합니다.
12번 정리 — 핵심 정리
서브쿼리는 괄호로 감싸고, 안쪽이 먼저 실행된다.
결과가 1행 1열이면 =, >, < 같은 일반 비교 연산자를 쓸 수 있다(단일행 서브쿼리).
결과가 2행 이상이면 =는 에러 — IN/ANY/ALL이 필요하다.
서브쿼리 안에서 바깥 테이블과 다른 테이블을 봐도 된다(dept를 보고 emp를 거르는 03번 예제).
WHERE뿐 아니라 SELECT·FROM 절에도 서브쿼리를 쓸 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
서브쿼리에 = 를 쓸 수 있는 조건은 무엇인가?
안쪽 결과가 정확히 1행 1열일 때만입니다. 2행 이상이면 Subquery returns more than 1 row 에러가 납니다. 지금 데이터로는 1행이지만 나중에 여러 행이 될 수 있는 쿼리가 특히 위험합니다 — 그때는 IN을 쓰는 게 안전합니다.
서브쿼리와 그냥 값을 적어 넣는 것(하드코딩)의 차이는?
데이터가 바뀌었을 때 살아남느냐의 차이입니다. "SMITH보다 월급 많은 사원"을 WHERE sal > 800으로 쓰면 지금은 맞지만 SMITH의 월급이 바뀌면 틀립니다. (SELECT sal FROM emp WHERE ename='SMITH')로 쓰면 언제 돌려도 의도대로 동작합니다.
💼 실무·코딩테스트에서는코딩테스트에서 하드코딩은 대부분 실패합니다 — 채점용 데이터가 예시와 다르기 때문입니다. 예시를 보고 값을 눈으로 확인해 적어 넣고 싶은 유혹이 들 때가 정확히 서브쿼리를 써야 할 순간입니다.
13
다중행 서브쿼리 — IN · ANY · ALL
INANYALL다중행
한 줄 요약서브쿼리 결과가 여러 행일 때 쓰는 연산자다. IN은 그 중 하나와 같으면, ANY는 그 중 하나라도 조건을 만족하면, ALL은 전부 만족해야 참이다.
쉽게 말하면>= ANY는 "그 무리 중 가장 작은 사람보다만 크면 통과"이고, >= ALL은 "그 무리 전원보다 커야 통과"입니다. 그래서 ANY는 기준이 최솟값, ALL은 기준이 최댓값이 됩니다. 아래 예제에서 경남 사람은 김범수(173)·윤종신(170) 둘인데, ANY는 170 기준이라 9명, ALL은 173 기준이라 7명이 나오는 걸로 차이를 확인할 수 있어요.
SQLsqlDB 서브쿼리 실습
-- 경남 사람 중 '누구 하나보다라도' 크거나 같으면 → 최솟값(170) 기준
SELECT NAME, height
FROM usertbl
WHERE height >= ANY (SELECT height FROM usertbl WHERE addr = '경남');
-- 경남 사람 '전원보다' 크거나 같아야 → 최댓값(173) 기준
SELECT NAME, height
FROM usertbl
WHERE height >= ALL (SELECT height FROM usertbl WHERE addr = '경남');
-- 키가 경남 사람의 키와 정확히 같은 사람
SELECT NAME, height
FROM usertbl
WHERE height IN (SELECT height FROM usertbl WHERE addr = '경남');
실행 결과ANY와 ALL을 나란히 실행한 결과 먼저 예측 → 펼쳐서 확인
[기준이 되는 경남 사람]
+-----------+--------+
| NAME | height |
+-----------+--------+
| 김범수 | 173 |
| 윤종신 | 170 |
+-----------+--------+
[>= ANY → 170 이상 : 9명]
바비킴176 은지원174 조관우172 김범수173 김경호177
임재범182 이승기182 성시경186 윤종신170
[>= ALL → 173 이상 : 7명]
바비킴176 은지원174 김범수173 김경호177
임재범182 이승기182 성시경186
ANY 결과(9명)에서 조관우(172)와 윤종신(170)이 빠진 것이 ALL 결과(7명)입니다. 두 사람은 170보다는 크거나 같지만 173보다는 작기 때문입니다. 이 관계 덕분에 > ALL (...)은 > (SELECT MAX(...))와 같은 뜻이 되고, 그래서 수업 문제에서 "MAX 함수를 쓰지 말고 풀어라"는 조건이 붙었을 때 ALL로 해결할 수 있습니다.
13번 정리 — 핵심 정리
IN (서브쿼리) — 결과 목록 중 하나와 같으면 참.
>= ANY (서브쿼리) — 목록 중 하나라도 만족하면 참. 결국 최솟값이 기준이 된다. = ANY는 IN과 같다.
>= ALL (서브쿼리) — 목록 전부를 만족해야 참. 결국 최댓값이 기준이 된다.
> ALL (...) ≡ > (SELECT MAX(...)), > ANY (...) ≡ > (SELECT MIN(...)) — MAX/MIN 금지 문제를 푸는 열쇠.
SOME은 ANY의 다른 이름으로 동작이 완전히 같다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
>= ANY 와 >= ALL 의 기준값이 각각 무엇이 되는지 설명해보세요.
>= ANY는 '하나라도 만족하면' 참이라 최솟값만 넘으면 통과합니다. >= ALL은 '전부 만족해야' 참이라 최댓값을 넘어야 합니다. 그래서 > ALL (...)은 > (SELECT MAX(...))와 같은 뜻이 됩니다.
MAX 함수를 쓰지 말고 '20번 부서 최고 급여보다 많이 받는 사원'을 구하려면?
WHERE sal > ALL (SELECT sal FROM emp WHERE deptno = 20)입니다. 20번 부서 전원보다 많아야 하니 결국 그 부서 최댓값을 넘는 것과 같습니다. ALL의 의미를 이해하면 MAX 없이도 풀립니다.
💼 실무·코딩테스트에서는실무에서는 가독성 때문에 MAX()를 쓸 수 있으면 그쪽을 씁니다. ALL은 처음 보는 사람이 한 번 더 생각해야 하거든요. 다만 시험·코딩테스트에서 'MAX 금지' 조건이 붙으면 ALL이 열쇠라, 둘의 등가 관계를 알아 두는 게 중요합니다.
14
NULL 함정 — NOT IN에 NULL이 섞이면 결과가 0행
NULLNOT INIFNULLCOALESCENVL
한 줄 요약NOT IN의 목록에 NULL이 하나라도 있으면 결과는 무조건 0행이 된다. SQL에서 NULL과의 비교는 참도 거짓도 아닌 UNKNOWN이기 때문이다. IFNULL로 NULL을 실제 값으로 바꿔 준 뒤 비교해야 한다.
쉽게 말하면NULL은 "0"이나 "빈 문자열"이 아니라 "값을 모름"이라는 뜻이에요. "모르는 값과 같지 않은가?"라고 물으면 SQL은 "그건 나도 모르겠다"라고 답합니다 — 참이 아니니까 그 행은 결과에서 빠집니다. NOT IN은 목록의 모든 값과 달라야 통과인데, 그 중 하나라도 "모르겠다"가 나오면 영원히 통과할 수 없게 되는 거죠. 그래서 결과가 통째로 비어 버립니다.
SQLscott 서브쿼리 실습 05 — 부하직원이 없는 사원 찾기
-- ❌ NULL을 처리하지 않으면 결과가 0행
SELECT empno, ename
FROM emp
WHERE empno NOT IN (SELECT mgr FROM emp);
-- ↑ 사장인 KING의 mgr은 NULL이다
-- ✅ NULL을 0으로 바꿔 준 뒤 비교하면 정상 동작
SELECT empno, ename
FROM emp
WHERE empno NOT IN (
SELECT IFNULL(mgr, 0)
FROM emp
);
실행 결과두 쿼리를 직접 실행해 비교한 결과 먼저 예측 → 펼쳐서 확인
[NULL 처리 없음]
SELECT COUNT(*) ... WHERE empno NOT IN (SELECT mgr FROM emp);
+--------------+
| 결과행수 |
+--------------+
| 0 |
+--------------+
[IFNULL(mgr, 0)으로 처리]
+-------+--------+
| empno | ename |
+-------+--------+
| 7369 | SMITH |
| 7499 | ALLEN |
| 7521 | WARD |
| 7654 | MARTIN |
| 7844 | TURNER |
| 7876 | ADAMS |
| 7900 | JAMES |
| 7934 | MILLER |
+-------+--------+ 8행
에러가 나지 않고 조용히 0행이 나오는 것이 이 함정의 무서운 점입니다. "데이터가 없나 보다"라고 넘어가기 쉽거든요. EMP 테이블에서 사장인 KING만 mgr이 NULL인데, 그 하나 때문에 14명 전체가 걸러져 버립니다. 참고로 수업 파일에는 Oracle 함수인 NVL(mgr, 0)이 쓰여 있었는데, 실제로 돌려 보니 MariaDB에서는 그대로 동작했습니다 — MariaDB가 Oracle 호환용으로 NVL을 지원하기 때문입니다. 다만 Oracle사가 만든 MySQL에는 NVL이 없으므로, 어디서나 통하는 IFNULL 또는 표준 SQL의 COALESCE를 쓰는 편이 안전합니다.
14번 정리 — 핵심 정리
NULL은 값이 아니라 "모름"이다. NULL = NULL조차 참이 아니다.
NULL 검사는 IS NULL / IS NOT NULL로만 한다.
NOT IN 목록에 NULL이 있으면 결과는 항상 0행 — 에러가 아니라 빈 결과라서 알아채기 어렵다.
NOT EXISTS를 쓰면 NULL 문제 자체가 생기지 않아, 실무에서는 이 방식을 더 선호한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
NOT IN에 NULL이 있으면 왜 참이 될 수 없는지 논리로 설명해보세요.
x NOT IN (a, b, NULL)은 x<>a AND x<>b AND x<>NULL로 풀립니다. 마지막 x<>NULL이 참도 거짓도 아닌 UNKNOWN이고, 참 AND UNKNOWN = UNKNOWN이라 전체가 절대 참이 되지 못합니다. 그래서 모든 행이 탈락합니다.
반대로 IN에 NULL이 섞여 있으면 어떻게 되나?
정상 동작합니다.IN은 OR로 풀리는데 참 OR UNKNOWN = 참이라, 다른 값과 일치하기만 하면 통과합니다. IN은 괜찮고 NOT IN만 위험한 이 비대칭이 함정의 핵심입니다.
💼 실무·코딩테스트에서는이 함정은 면접 SQL 단골 질문입니다. "NOT IN과 NOT EXISTS의 차이는?"이라고 물으면 이 NULL 이야기를 기대하는 것입니다. 실무에서 NOT EXISTS를 선호하는 이유도 여기 있습니다.
15
ORDER BY — 결과를 원하는 순서로 줄 세우기
ORDER BYASCDESC다중정렬
한 줄 요약ORDER BY 컬럼 [ASC|DESC]로 결과를 정렬한다. 쉼표로 여러 기준을 적으면 앞의 기준이 1순위이고, 값이 같을 때만 뒤의 기준이 쓰인다. 생략하면 오름차순(ASC)이다.
쉽게 말하면명단을 키 큰 순으로 세우되, 키가 같으면 이름 가나다순으로 세우는 것과 같아요. 1순위 기준으로 먼저 줄을 세우고, 동점자끼리만 2순위로 다시 정렬합니다. 주의할 점은 SQL은 ORDER BY를 쓰지 않으면 순서를 보장하지 않는다는 것 — 지금 순서대로 나온 건 우연일 수 있습니다.
SQLsqlDB 실습
-- 키 내림차순, 키가 같으면 이름 오름차순
SELECT NAME, height
FROM usertbl
ORDER BY height DESC, NAME ASC;
키 182인 이승기·임재범이 동점인데, 2순위 기준(이름 오름차순)에 따라 이승기가 앞에 왔습니다. 만약 ORDER BY height DESC만 썼다면 이 둘의 순서는 보장되지 않습니다 — 실행할 때마다 달라질 수 있습니다.
15번 정리 — 핵심 정리
ASC는 오름차순(기본값), DESC는 내림차순. ASC는 생략 가능하다.
쉼표로 여러 기준을 나열하면 앞에 적은 것이 1순위다: ORDER BY height DESC, NAME ASC.
기준마다 방향을 따로 지정한다 — DESC, ASC처럼 섞어 쓸 수 있다.
ORDER BY가 없으면 순서는 보장되지 않는다. 정렬이 필요하면 반드시 명시할 것.
실행 순서상 ORDER BY는 가장 마지막이라, SELECT에서 만든 별칭을 쓸 수 있다(WHERE는 못 씀).
함정 — CONCAT 등으로 문자열이 된 값을 정렬하면 '100km' < '10km' < '2km'처럼 사전순이 된다. 숫자 순서가 필요하면 가공 전 원본 식으로 정렬해야 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
ORDER BY를 쓰지 않으면 결과 순서는 어떻게 되나?
보장되지 않습니다. 지금 순서대로 나온 것은 우연히 저장 순서나 인덱스 순서를 따랐을 뿐이고, 데이터가 늘거나 실행 계획이 바뀌면 달라질 수 있습니다. 순서가 중요하면 반드시 명시해야 합니다.
ORDER BY에서는 SELECT의 별칭을 쓸 수 있는데 WHERE에서는 안 되는 이유는?
실행 순서 때문입니다. FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY 순이라, WHERE 시점에는 아직 별칭이 만들어지지 않았고 ORDER BY 시점에는 이미 만들어져 있습니다.
💼 실무·코딩테스트에서는실무에서 페이지네이션(1페이지, 2페이지…)을 만들 때 ORDER BY가 불완전하면 같은 행이 두 페이지에 나오거나 아예 빠집니다. 정렬 기준이 중복될 수 있으면 ORDER BY 날짜 DESC, id DESC처럼 유일한 컬럼을 마지막에 덧붙여 순서를 확정하는 게 정석입니다.
16
테이블 복사 — CREATE TABLE ~ SELECT
CREATE TABLE AS서브쿼리WHERE 1=0ALTER TABLE
한 줄 요약CREATE TABLE 새이름 AS SELECT ...는 조회 결과를 그대로 새 테이블로 만든다. 편리하지만 PK·FK 같은 제약조건은 복사되지 않는다 — 필요하면 ALTER TABLE로 따로 붙여야 한다.
쉽게 말하면복사기로 내용만 복사하는 것과 같아요. 글자(데이터)와 칸 모양(컬럼·자료형)은 그대로 옮겨지지만, "이 칸은 중복되면 안 됨" 같은 규칙(제약조건)은 따라오지 않습니다. 실습용 테이블을 빠르게 만들 때 아주 유용하고, WHERE 1=0처럼 절대 참이 될 수 없는 조건을 주면 데이터 없이 껍데기 구조만 복사됩니다.
SQLsqlDB · scott 실습
-- 데이터까지 통째로 복사 (제약조건은 안 따라온다)
CREATE TABLE buytbl2 AS SELECT * FROM buytbl;
CREATE TABLE test01 AS SELECT * FROM scott.emp;
-- 원하는 컬럼만, 이름까지 바꿔서 복사
CREATE TABLE test03 AS
SELECT empno AS M1, ename AS M2 FROM scott.emp;
-- 구조(껍데기)만 복사 : 참이 될 수 없는 조건을 준다
CREATE TABLE test04 AS
SELECT * FROM scott.emp WHERE 1 = 0;
-- 빠진 제약조건은 나중에 따로 붙인다
ALTER TABLE buytbl2
ADD CONSTRAINT pk_buytbl2_num PRIMARY KEY (num);
실행 결과구조만 복사한 뒤 확인한 결과 먼저 예측 → 펼쳐서 확인
SELECT COUNT(*) FROM test04;
+--------+
| 행수 |
+--------+
| 0 |
+--------+
SHOW KEYS FROM test04;
(결과 없음 — 키가 하나도 없다)
행 수는 0으로 구조만 복사됐고, SHOW KEYS 결과가 비어 있어 원본 emp의 기본키가 따라오지 않은 것이 확인됩니다. 그래서 ALTER TABLE ... ADD CONSTRAINT로 직접 붙여야 합니다. FOREIGN KEY·AUTO_INCREMENT·인덱스도 마찬가지로 복사되지 않습니다. 반대로 컬럼 정의에 붙은 NOT NULL과 DEFAULT는 컬럼과 함께 따라옵니다.
16번 정리 — 핵심 정리
CREATE TABLE 새이름 AS SELECT ... — 조회 결과를 새 테이블로 만든다. AS는 생략하고 괄호로 감싸도 된다.
복사되는 것: 컬럼명·자료형·데이터·NOT NULL·DEFAULT. 복사되지 않는 것: PRIMARY KEY·FOREIGN KEY·AUTO_INCREMENT·인덱스.
WHERE 1 = 0은 항상 거짓이라 행이 하나도 안 딸려온다 — 구조만 복사할 때 쓰는 관용구.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
CREATE TABLE ... AS SELECT 로 복사하면 무엇이 따라오고 무엇이 안 따라오나?
따라오는 것: 컬럼명·자료형·데이터, 그리고 컬럼에 붙은 NOT NULL·DEFAULT. 안 따라오는 것: PRIMARY KEY·FOREIGN KEY·AUTO_INCREMENT·인덱스. 결과 표의 '모양'은 복사되지만 키와 인덱스는 빠집니다.
WHERE 1 = 0 이 구조만 복사하는 원리는?
1 = 0은 어떤 행에 대해서도 거짓이라 조건을 통과하는 행이 하나도 없습니다. 그래서 컬럼 구조는 결정되지만 데이터는 0행이 됩니다. LIMIT 0으로도 같은 효과를 낼 수 있습니다.
💼 실무·코딩테스트에서는실무에서 위험한 작업 전에 백업 테이블을 만드는 용도로 가장 많이 씁니다 — CREATE TABLE emp_bak_20260908 AS SELECT * FROM emp; 한 줄이면 되니까요. 다만 제약조건이 빠진 사본이라는 걸 기억해야 복원할 때 당황하지 않습니다.
17
집계 함수 — COUNT · SUM · AVG · MAX · MIN과 NULL
집계함수COUNTAVGNULL
한 줄 요약여러 행을 하나의 값으로 요약하는 함수다. 핵심은 COUNT(*)를 뺀 모든 집계 함수가 NULL을 무시한다는 것 — 그래서 AVG의 분모는 전체 행 수가 아니라 값이 있는 행 수다.
쉽게 말하면표의 한 열을 세로로 훑어서 숫자 하나로 압축하는 도구예요. 가장 헷갈리는 건 NULL 처리입니다. 사원 14명 중 커미션이 있는 사람이 4명일 때, AVG(comm)은 4로 나눈 값이지 14로 나눈 값이 아닙니다. "전체 사원 기준 평균"을 원했다면 결과가 3배 넘게 부풀려지는 셈이죠.
SQLscott 실습
-- COUNT(*) 는 행을 세고, COUNT(컬럼) 은 NULL이 아닌 값을 센다
SELECT COUNT(*) AS 전체행,
COUNT(comm) AS comm있는행,
AVG(comm) AS comm평균
FROM emp;
-- 부서별 평균·최대·인원
-- (GROUP BY = 같은 값끼리 묶어 묶음마다 따로 집계 — 자세한 건 18번 카드)
SELECT deptno, AVG(sal), MAX(sal), COUNT(*)
FROM emp
GROUP BY deptno;
커미션 값은 300 · 500 · 1400 · 0 네 개뿐입니다. 합이 2200이므로 2200 ÷ 4 = 550이 나왔습니다. 만약 전체 14명으로 나눴다면 2200 ÷ 14 ≈ 157이 됐을 겁니다. 어느 쪽이 맞는지는 문제가 무엇을 묻는지에 달려 있습니다 — 전체 기준 평균이 필요하면 AVG(IFNULL(comm,0)) 또는 SUM(comm)/COUNT(*)로 써야 합니다.
17번 정리 — 핵심 정리
COUNT(*) = 행 개수(NULL 포함) / COUNT(컬럼) = 그 컬럼이 NULL이 아닌 행의 개수.
SUM·AVG·MAX·MIN은 NULL을 아예 빼고 계산한다 — 특히 AVG의 분모가 달라진다.
전체 행 기준 평균이 필요하면 AVG(IFNULL(컬럼,0))으로 NULL을 0으로 채운 뒤 계산한다.
COUNT(DISTINCT 컬럼)은 중복을 뺀 값의 개수를 센다.
집계 함수는 중첩할 수 없다 — MAX(SUM(sal))은 에러다. 합계 중 최댓값은 ORDER BY SUM(sal) DESC LIMIT 1이나 서브쿼리로 푼다.
집계 함수는 WHERE에서 쓸 수 없다(WHERE가 먼저 실행되기 때문). 조건을 걸려면 HAVING을 쓴다(GROUP BY로 묶은 뒤 그룹에 거는 조건 — 18번·19번 카드).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
COUNT(*)와 COUNT(컬럼)의 차이는 무엇이며 언제 갈리나?
COUNT(*)는 행 개수를, COUNT(컬럼)은 그 컬럼이 NULL이 아닌 행을 셉니다. 평소엔 같아 보이다가 NULL이 있거나 OUTER JOIN을 한 순간 결과가 갈립니다.
사원 14명 중 커미션이 있는 사람이 4명일 때 AVG(comm)의 값은 무엇을 나눈 것인가?
커미션 합계를 4로 나눈 값입니다(2200÷4=550). 14로 나눈 157이 아닙니다. 집계 함수는 NULL을 아예 빼고 계산하기 때문이죠. '전체 사원 기준 평균'이 필요하면 AVG(IFNULL(comm,0))으로 써야 합니다.
💼 실무·코딩테스트에서는실무 지표에서 이 분모 차이가 숫자를 크게 왜곡합니다. '평균 구매액'을 낼 때 구매 이력이 없는 회원을 분모에 넣느냐 빼느냐로 값이 몇 배 달라지죠. 지표를 정의할 때 분모를 먼저 합의하는 것이 데이터 업무의 기본입니다.
18
GROUP BY — 같은 값끼리 묶어 그룹별로 집계하기
GROUP BY그룹별집계ONLY_FULL_GROUP_BY
한 줄 요약GROUP BY 컬럼은 그 컬럼의 값이 같은 행들을 한 덩어리로 묶고, 집계 함수를 덩어리마다 따로 계산한다. SELECT 절에는 묶은 기준 컬럼과 집계 함수만 오는 것이 원칙이다.
쉽게 말하면전체를 하나로 요약하던 집계 함수를 "부서별로", "직업별로" 나눠서 계산하게 만드는 스위치예요. GROUP BY deptno를 붙이면 10번·20번·30번 부서가 각각 별개의 묶음이 되고, AVG(sal)이 묶음마다 한 번씩 계산돼 세 줄이 나옵니다. 붙이지 않으면 전체 평균 한 줄이 나오죠.
SQLscott · sqlDB 실습
-- 부서별 평균 월급
SELECT deptno, AVG(sal)
FROM emp
GROUP BY deptno;
-- 직업별 최대 월급
SELECT job, MAX(sal)
FROM emp
GROUP BY job;
-- 사용자별 총 구매액
SELECT userid AS '사용자', SUM(price * amount) AS '총구매액'
FROM buytbl
GROUP BY userid;
-- 부서별로 여러 집계를 한 번에 (아래 실행 결과는 이 쿼리)
SELECT deptno, AVG(sal) AS 평균, MAX(sal) AS 최대, COUNT(*) AS 인원
FROM emp
GROUP BY deptno
ORDER BY deptno;
수업 파일에서 두 군데 문법 문제를 찾아 직접 확인했습니다. ① SELECT deptno AVG(sal) ...는 쉼표가 빠져ERROR 1064 (syntax error)가 납니다. ② SELECT job, MAX(sal) FROM emp WHERE job='salesman';처럼 GROUP BY 없이 일반 컬럼과 집계 함수를 섞은 쿼리는 MariaDB 기본 sql_mode에서는 에러 없이 통과합니다. 하지만 ONLY_FULL_GROUP_BY가 켜진 환경(MySQL 5.7 이상은 기본으로 켜짐)에서는 ERROR 1140이 납니다. 참고로 GROUP BY가 있는데 묶지 않은 컬럼을 SELECT에 두면 ERROR 1055입니다. 이런 쿼리는 환경에 따라 되기도 하고 안 되기도 해서 시험·실무 모두 위험합니다.
18번 정리 — 핵심 정리
GROUP BY 컬럼 — 값이 같은 행끼리 묶고, 집계 함수를 묶음마다 계산한다.
SELECT 절에는 GROUP BY에 적은 컬럼과 집계 함수만 두는 것이 표준이다.
MySQL 5.7+는 ONLY_FULL_GROUP_BY가 기본이라 이를 어기면 에러가 난다 — GROUP BY가 있으면 ERROR 1055, GROUP BY 없이 집계와 일반 컬럼을 섞으면 ERROR 1140. MariaDB 기본 설정에서는 통과해서 실수를 못 잡는 경우가 있다.
여러 컬럼으로 묶을 수도 있다: GROUP BY deptno, job — 부서·직업 조합마다 한 줄.
쉼표 빠뜨림에 주의 — SELECT deptno AVG(sal)은 문법 에러(ERROR 1064)다.
가공한 식으로도 묶을 수 있다: GROUP BY YEAR(ym), GROUP BY TRUNCATE(price,-4).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
GROUP BY deptno 를 붙이면 결과 행 수가 어떻게 변하는가?
부서 개수만큼으로 줄어듭니다. 사원 14행이 부서 3행이 되죠. 이때 사원 이름 같은 개별 정보는 사라집니다 — 한 부서에 여러 이름이 있는데 한 줄에 담을 수 없으니까요. 그래서 SELECT에는 묶은 기준과 집계 함수만 올 수 있습니다.
MariaDB에서는 되는데 MySQL에서는 에러 나는 GROUP BY 쿼리가 있는 이유는?
MySQL 5.7부터 ONLY_FULL_GROUP_BY가 기본으로 켜져 있기 때문입니다. 이 모드는 '묶은 기준이 아닌 컬럼'을 SELECT에 두는 것을 금지합니다. MariaDB 기본 설정은 관대해서 통과시키는데, 그래서 오히려 실수를 못 잡습니다.
💼 실무·코딩테스트에서는실무에서 개발 DB와 운영 DB의 sql_mode가 다르면 개발에선 잘 되던 쿼리가 배포 후 터집니다. 표준에 맞춰 묶은 컬럼은 전부 GROUP BY에 적는 습관을 들이면 어느 환경에서도 안전합니다.
19
HAVING vs WHERE — 조건을 거는 두 자리
HAVINGWHERE실행순서
한 줄 요약WHERE는 묶기 전에 개별 행을 거르고, HAVING은 묶은 뒤 그룹을 거른다. 그래서 집계 함수 조건(SUM(sal) > 5000)은 WHERE에 쓸 수 없고 반드시 HAVING에 써야 한다.
쉽게 말하면순서를 생각하면 헷갈리지 않아요. ① WHERE로 필요 없는 사람을 먼저 빼고 → ② GROUP BY로 부서별로 묶고 → ③ HAVING으로 조건에 안 맞는 부서(묶음)를 통째로 뺍니다. "MANAGER인 사람은 제외"는 개인에 대한 조건이니 WHERE, "총 월급이 5000 넘는 직업만"은 묶음에 대한 조건이니 HAVING입니다.
SQLscott 실습
-- MANAGER는 빼고(WHERE), 직업별 총월급이 5000 넘는 것만(HAVING)
SELECT job, SUM(sal)
FROM emp
WHERE job <> 'MANAGER' -- ① 묶기 전, 개별 행 조건
GROUP BY job -- ② 직업별로 묶기
HAVING SUM(sal) > 5000 -- ③ 묶은 뒤, 그룹 조건
ORDER BY SUM(sal) DESC; -- ④ 마지막에 정렬
수업 파일의 이 쿼리는 위험합니다 — SELECT ename, sal FROM emp WHERE job='salesman' GROUP BY ename HAVING sal >= 1000;. sal은 집계 함수가 아닌데 HAVING에 쓰였고 GROUP BY에도 없습니다. 직접 돌려 보니 MariaDB 기본 설정에서는 결과가 나오지만, ONLY_FULL_GROUP_BY를 켜면 ERROR 1055: 'scott.emp.SAL' isn't in GROUP BY가 납니다. 애초에 이 문제는 그룹 조건이 아니라 개인 조건이므로 WHERE job='salesman' AND sal >= 1000이 정답입니다.
19번 정리 — 핵심 정리
실행 순서: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY.
WHERE = 행 조건(묶기 전) / HAVING = 그룹 조건(묶은 뒤).
집계 함수 조건은 WHERE에 쓸 수 없다 — WHERE SUM(sal)>5000은 에러, HAVING SUM(sal)>5000이 맞다.
반대로 집계와 무관한 조건은 WHERE에 쓰는 게 좋다 — 먼저 걸러내므로 더 빠르다.
HAVING에 집계 함수가 아닌 컬럼을 쓰면 ONLY_FULL_GROUP_BY 환경에서 ERROR 1055가 난다.
WHERE는 GROUP BY 없이도 쓰지만, HAVING은 보통 GROUP BY와 함께 쓴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
집계 함수 조건을 WHERE에 쓸 수 없는 이유를 실행 순서로 설명해보세요.
WHERE는 GROUP BY보다 먼저 실행됩니다. 그 시점에는 아직 그룹이 만들어지지 않았으니 SUM()·COUNT() 같은 집계값이 존재하지도 않습니다. 그래서 묶은 뒤에 동작하는 HAVING이 따로 필요합니다.
'MANAGER는 제외하고, 직업별 총급여가 5000 넘는 것만' 을 WHERE/HAVING에 어떻게 나눠 쓰나?
MANAGER 제외는 WHERE(개인에 대한 조건), 총급여 5000 초과는 HAVING(묶음에 대한 조건)입니다. 판단 기준은 간단합니다 — 묶어 봐야 알 수 있는 조건이면 HAVING, 한 행만 봐도 알 수 있으면 WHERE.
💼 실무·코딩테스트에서는성능 면에서도 WHERE에 쓸 수 있는 조건은 WHERE에 쓰는 게 맞습니다. 미리 걸러내면 묶을 대상 자체가 줄어들거든요. HAVING은 이미 다 계산한 뒤 버리는 것이라 낭비가 생깁니다. 실무 쿼리 튜닝의 기본 원칙입니다.
20
SQL 3분류(DDL·DML·DCL)와 INSERT · AUTO_INCREMENT 제어
DDLDMLDCLAUTO_INCREMENT
한 줄 요약SQL 명령은 구조를 다루는 DDL(CREATE·ALTER·DROP·TRUNCATE), 데이터를 다루는 DML(SELECT·INSERT·UPDATE·DELETE), 권한을 다루는 DCL(GRANT·REVOKE)로 나뉜다. AUTO_INCREMENT는 시작값과 증가폭을 바꿀 수 있다.
쉽게 말하면서류 창고에 비유하면 — DDL은 "서랍장을 새로 짜거나 부수는" 목수 일, DML은 "서류를 넣고 빼고 고치는" 사무 일, DCL은 "누가 어느 서랍을 열 수 있는지 정하는" 관리자 일입니다. 중요한 차이는 DDL은 되돌릴 수 없다(자동 커밋)는 점 — DML은 트랜잭션으로 취소할 수 있지만 DDL은 실행하는 순간 확정입니다.
SQLsqlDB 실습
-- [DDL] 실습용 테이블 만들기 (구조 정의)
CREATE TABLE testtbl1 (id INT, userName CHAR(3), age INT);
CREATE TABLE testtbl2 (
id INT AUTO_INCREMENT PRIMARY KEY,
username CHAR(3),
age INT
);
-- [DML] INSERT : 컬럼을 지정하면 순서를 바꿔도 되고, 일부만 넣어도 된다
INSERT INTO testtbl1 VALUES(1, '홍길동', 25); -- 전체 컬럼 순서대로
INSERT INTO testtbl1(id, username) VALUES(2, '설현'); -- 일부만 (age는 NULL)
INSERT INTO testtbl1(username, id) VALUES('설현2', 3); -- 순서를 바꿔서
INSERT INTO testtbl2 VALUES(NULL, '정우성', 50); -- id 자동: 1
-- [DDL] AUTO_INCREMENT 시작값 바꾸기
ALTER TABLE testtbl2 AUTO_INCREMENT = 100;
-- 증가폭 바꾸기 (세션 변수 — 이 접속의 '모든 테이블'에 적용된다)
SET @@AUTO_INCREMENT_INCREMENT = 3;
-- [DML] 한 문장으로 여러 행 넣기 (쉼표로 이어 붙인다)
INSERT INTO testtbl2 VALUES(NULL, '정우성', 50),
(NULL, '정우일', 50),
(NULL, '정우이', 50);
SELECT * FROM testtbl2;
-- 실습이 끝나면 증가폭을 되돌린다
SET @@AUTO_INCREMENT_INCREMENT = 1;
-- [DCL] 권한 주기 예시 (수업 파일에는 없음 — 형태만 참고)
-- GRANT SELECT ON sqldb.* TO '사용자'@'localhost';
실행 결과AUTO_INCREMENT 제어 결과 먼저 예측 → 펼쳐서 확인
SELECT * FROM testtbl2;
+-----+----------+------+
| id | username | age |
+-----+----------+------+
| 1 | 정우성 | 50 | ← ALTER 전에 들어간 행
| 100 | 정우성 | 50 | ← AUTO_INCREMENT = 100 부터 시작
| 103 | 정우일 | 50 | ← 증가폭 3
| 106 | 정우이 | 50 |
+-----+----------+------+
INSERT INTO 테이블(컬럼목록) VALUES(...) 형태를 쓰면 컬럼 순서를 바꿔 적어도 되고 일부만 넣어도 됩니다. 나머지 컬럼은 기본값이나 NULL로 채워지므로, NOT NULL이면서 기본값이 없는 컬럼을 빠뜨리면 에러가 납니다. 여러 행을 한 문장으로 넣는 방식이 한 행씩 여러 번 넣는 것보다 훨씬 빠릅니다.
@@AUTO_INCREMENT_INCREMENT는 테이블 설정이 아니라 세션(접속) 변수라서, 바꾼 뒤에는 같은 접속에서 INSERT하는 다른 테이블의 AUTO_INCREMENT도 3씩 뜁니다. 실습 후에는 1로 되돌려 두세요.
20번 정리 — 핵심 정리
DDL(Data Definition Language): CREATE · ALTER · DROP · TRUNCATE — 구조를 정의한다. 자동 커밋이라 롤백이 안 된다.
DML(Data Manipulation Language): SELECT · INSERT · UPDATE · DELETE — 데이터를 다룬다. 트랜잭션으로 취소할 수 있다.
DCL(Data Control Language): GRANT · REVOKE — 권한을 준다/뺏는다. COMMIT·ROLLBACK은 따로 TCL로 부르기도 한다.
INSERT INTO 테이블(컬럼들) VALUES(값들) — 컬럼을 명시하면 순서를 바꿔도 되고 일부만 넣어도 된다.
ALTER TABLE 테이블 AUTO_INCREMENT = 100;으로 시작 번호를, SET @@AUTO_INCREMENT_INCREMENT = 3;으로 증가폭을 바꾼다. 증가폭은 세션의 모든 테이블에 적용된다.
여러 행 한 번에: VALUES (...), (...), (...) — 반복 INSERT보다 훨씬 빠르다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DDL과 DML의 결정적 차이 하나를 말해보세요.
DDL은 자동 커밋이라 롤백이 안 됩니다.CREATE·DROP·TRUNCATE는 실행하는 순간 확정입니다. 반면 DML(INSERT·UPDATE·DELETE)은 트랜잭션으로 묶어 되돌릴 수 있습니다. '되돌릴 수 있는가'가 실무에서 가장 중요한 구분입니다.
AUTO_INCREMENT 컬럼에 NULL을 넣는 게 이상하지 않은 이유는?
여기서 NULL은 '값이 없다'가 아니라 'DB야, 네가 알아서 채워라'는 신호로 약속되어 있기 때문입니다. 명시적으로 숫자를 넣으면 그 값이 그대로 들어가고, 이후 자동 번호는 그보다 큰 값부터 이어집니다.
💼 실무·코딩테스트에서는실무에서 운영 DB에 DDL을 치는 것은 배포 절차를 따르는 일입니다. ALTER TABLE 한 줄이 큰 테이블에서는 몇 분간 락을 잡아 서비스를 멈출 수 있거든요. '되돌릴 수 없다'는 성질과 합쳐져서, DDL은 항상 계획하고 승인받고 실행합니다.
21
UPDATE · DELETE — WHERE를 빠뜨리면 전체가 바뀐다
UPDATEDELETEWHERE 필수CASE
한 줄 요약UPDATE 테이블 SET 컬럼=값 WHERE 조건은 조건에 맞는 행의 값을 바꾸고, DELETE FROM 테이블 WHERE 조건은 행을 지운다. WHERE를 빠뜨리면 테이블의 모든 행이 대상이 된다.
쉽게 말하면가장 무서운 명령입니다. WHERE 한 줄을 빠뜨리면 테이블 전체가 한 번에 바뀌거나 지워집니다. 그래서 실무에서는 같은 조건으로 SELECT를 먼저 돌려 보고, 원하는 행만 나오는 걸 확인한 뒤 SELECT 자리를 UPDATE/DELETE로 바꾸는 습관을 들입니다.
SQLscott 연습문제 9 (emp2 복사본에서 실습)
-- 복사본을 만들어 놓고 연습한다 (원본은 건드리지 않는다)
CREATE TABLE emp2 AS SELECT * FROM emp;
-- 문제 1: SALESMAN인 사원 급여에 400 더하기 (수업 파일 코드 그대로 — 아래 실행 결과 참고)
UPDATE emp2
SET sal = sal - 400 -- 수업 파일 주의: 문제는 '더하기'이므로 sal + 400 이 맞다
WHERE job = 'SALESMAN';
-- 평균 급여보다 높은 사원의 입사일을 1년 뒤로
UPDATE emp2
SET hiredate = ADDDATE(hiredate, INTERVAL 1 YEAR)
WHERE sal > (SELECT avg_sal FROM (SELECT AVG(sal) AS avg_sal FROM emp2) AS temp);
-- 직업에 따라 서로 다른 배수를 적용 (CASE)
-- WHERE 없음: 문제가 '전체 사원 대상'이라 일부러 모든 행을 바꾸는 의도적 전체 갱신
UPDATE emp2
SET comm = IFNULL(comm, 0) + 100,
sal = CASE job
WHEN 'CLERK' THEN sal * 2
WHEN 'MANAGER' THEN sal * 3
ELSE sal * 4
END;
-- 이름이 M으로 시작하는 사원 삭제
DELETE FROM emp2 WHERE ename LIKE 'M%';
실행 결과첫 번째 UPDATE(문제 1) 실행 전후 — 수업 파일의 부호 실수먼저 예측 → 펼쳐서 확인
문제: "SALESMAN인 사원 급여에 400을 더하는 UPDATE"
수업 코드: UPDATE emp2 SET sal = sal - 400 WHERE job='SALESMAN';
실행 전 → 실행 후
ALLEN 1600 → 1200
WARD 1250 → 850
MARTIN 1250 → 850
TURNER 1500 → 1100
문제는 "400을 더하라"인데 코드는 sal - 400으로 빼고 있습니다. 실행해 보니 1600이 1200으로 줄어드는 게 확인됩니다. 문법은 완벽히 맞아서 에러가 전혀 나지 않고, 결과만 정반대가 됩니다 — 이런 부호 실수가 가장 찾기 어렵습니다. sal = sal + 400이 맞습니다.
또 하나: MySQL에서는 수정·삭제하려는 테이블을 서브쿼리에서 직접 읽을 수 없어(ERROR 1093) (SELECT ... FROM (SELECT ...) AS temp)처럼 한 겹 더 감싸는 우회가 필요합니다. 수업 코드가 그렇게 되어 있는 이유입니다. 반면 MariaDB 10.3.2 이상은 UPDATE·DELETE에서 같은 테이블을 읽는 서브쿼리를 허용해서 감싸지 않아도 실행됩니다 — 감싸 두면 두 DB 모두에서 안전합니다.
21번 정리 — 핵심 정리
UPDATE 테이블 SET 컬럼=값, 컬럼2=값2 WHERE 조건; — 쉼표로 여러 컬럼을 동시에 바꾼다.
DELETE FROM 테이블 WHERE 조건; — WHERE를 빼면 전체 행이 지워진다.
실행 전에 같은 조건으로 SELECT를 먼저 돌려 확인하는 습관을 들일 것.
CASE 컬럼 WHEN 값 THEN 결과 ... ELSE 기본값 END로 행마다 다른 값을 계산해 넣을 수 있다.
MySQL은 UPDATE/DELETE 대상 테이블을 서브쿼리에서 바로 참조하지 못한다(ERROR 1093) — FROM (SELECT ...) AS 별칭으로 한 겹 감싸면 우회된다. (MariaDB 10.3.2+는 감싸지 않아도 허용)
날짜 연산: ADDDATE(날짜, INTERVAL 1 YEAR) — YEAR·MONTH·DAY 등을 쓸 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
UPDATE에서 WHERE를 빠뜨리면 무슨 일이 생기나?
테이블의 모든 행이 바뀝니다. 에러도 경고도 없이 성공합니다. DELETE도 마찬가지로 전체가 지워집니다. SQL에서 가장 자주 일어나는 사고가 이것이고, 그래서 실행 전 SELECT로 확인하는 습관이 필요합니다.
MySQL에서 수정 대상 테이블을 서브쿼리로 직접 읽으면 왜 에러가 나나?
ERROR 1093 — 읽으면서 동시에 쓰는 상태가 되어 결과가 불안정해지기 때문입니다. FROM (SELECT ...) AS temp로 한 겹 감싸면 안쪽이 먼저 임시 결과로 확정된 뒤 바깥이 실행되므로 우회됩니다.
💼 실무·코딩테스트에서는실무에서는 UPDATE·DELETE에 LIMIT을 붙여 안전장치를 두기도 합니다. 또 많은 팀이 운영 DB 접속 도구에 'WHERE 없는 UPDATE/DELETE 차단' 설정을 켜 둡니다. 사람은 반드시 실수한다는 전제로 도구를 세팅하는 것이죠.
22
DELETE · TRUNCATE · DROP — 지우는 세 가지 방법
DELETETRUNCATEDROPAUTO_INCREMENT
한 줄 요약DELETE는 행을 골라 지우고(테이블은 남음), TRUNCATE는 전체 행을 통째로 비우고(테이블은 남음), DROP은 테이블 자체를 없앤다. TRUNCATE는 DDL이라 AUTO_INCREMENT 번호까지 초기화된다.
쉽게 말하면책장에 비유하면 — DELETE는 책을 한 권씩 골라 빼는 것, TRUNCATE는 책장을 통째로 털어 비우는 것, DROP은 책장 자체를 버리는 것입니다. DELETE만 조건을 걸 수 있고 되돌릴 수 있으며, 나머지 둘은 DDL이라 실행 즉시 확정입니다.
SQLsqlDB 실습
-- 대용량 테이블을 세 개 복사해 놓고 차이를 비교
CREATE TABLE bigtbl1 AS SELECT * FROM employees.employees;
CREATE TABLE bigtbl2 AS SELECT * FROM employees.employees;
CREATE TABLE bigtbl3 AS SELECT * FROM employees.employees;
DELETE FROM bigtbl1; -- 행을 하나씩 지운다 (느림, 되돌릴 수 있음)
DROP TABLE bigtbl2; -- 테이블 자체를 없앤다
TRUNCATE TABLE bigtbl3; -- 통째로 비운다 (빠름, 되돌릴 수 없음)
-- AUTO_INCREMENT 비교용 작은 테이블 (수업 파일 밖의 확인용 예제)
-- bigtbl은 CTAS 복사본이라 AUTO_INCREMENT 컬럼이 없어서 따로 만든다
CREATE TABLE ai1 (id INT AUTO_INCREMENT PRIMARY KEY, v CHAR(1));
INSERT INTO ai1 (v) VALUES ('a'), ('b'), ('c'); -- id 1, 2, 3
DELETE FROM ai1;
INSERT INTO ai1 (v) VALUES ('d'); -- id = ?
TRUNCATE TABLE ai1;
INSERT INTO ai1 (v) VALUES ('e'); -- id = ?
실행 결과ai1 테이블로 AUTO_INCREMENT 초기화 여부를 비교한 결과먼저 예측 → 펼쳐서 확인
id가 1,2,3까지 들어간 테이블에서
DELETE FROM ai1; → 다시 INSERT 하면 id = 4 (번호 이어짐)
TRUNCATE TABLE ai1; → 다시 INSERT 하면 id = 1 (번호 초기화)
이것이 DELETE와 TRUNCATE의 눈에 보이는 가장 큰 차이입니다. DELETE는 "행만 지운" 것이라 다음 번호가 4부터 이어지지만, TRUNCATE는 테이블을 새로 만든 것과 같은 상태로 되돌려 1부터 다시 시작합니다. 실습 데이터를 초기화할 때 번호까지 깔끔하게 리셋하고 싶다면 TRUNCATE가 맞습니다.
22번 정리 — 핵심 정리
DELETE — DML. 조건(WHERE)으로 골라 지울 수 있고, 트랜잭션으로 되돌릴 수 있다. 행 단위로 지워 느리다. 단, MySQL·MariaDB는 기본이 autocommit(문장마다 바로 확정)이라, START TRANSACTION;(또는 SET autocommit=0;)으로 시작한 뒤에 지워야 ROLLBACK으로 되돌릴 수 있다.
TRUNCATE — DDL. 전체 행만 삭제, 조건을 걸 수 없고 되돌릴 수 없다. 매우 빠르며 AUTO_INCREMENT가 초기화된다.
DROP — DDL. 테이블 자체가 사라진다(구조까지). 다시 쓰려면 CREATE부터 해야 한다.
속도: TRUNCATE ≫ DELETE. 대용량 테이블을 비울 때 차이가 크다.
외래키로 참조되고 있는 테이블은 TRUNCATE·DROP이 거부된다 — 자식 테이블부터 정리해야 한다.
셋 다 실수하면 복구가 어렵다 — 실행 전에 대상 테이블 이름을 다시 확인할 것.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DELETE와 TRUNCATE의 차이를 세 가지 대보세요.
① 조건 — DELETE는 WHERE로 골라 지울 수 있고 TRUNCATE는 전체만. ② 되돌리기 — DELETE는 롤백 가능(DML), TRUNCATE는 불가(DDL). ③ AUTO_INCREMENT — DELETE는 번호가 이어지고, TRUNCATE는 1로 초기화됩니다.
대용량 테이블을 비울 때 TRUNCATE가 훨씬 빠른 이유는?
DELETE는 행을 하나씩 지우며 되돌리기용 로그를 남기지만, TRUNCATE는 테이블을 통째로 버리고 새로 만드는 것에 가깝기 때문입니다. 그래서 빠른 대신 되돌릴 수 없습니다 — 속도와 안전성의 맞바꿈입니다.
💼 실무·코딩테스트에서는실무에서 테스트 데이터 초기화에는 TRUNCATE, 운영 데이터 정리에는 조건부 DELETE가 기본입니다. 다만 외래키로 참조되는 테이블은 TRUNCATE가 거부되므로, 자식부터 정리하거나 DELETE를 써야 합니다.
23
서브쿼리 (2) — 스칼라 서브쿼리와 인라인 뷰
스칼라 서브쿼리인라인 뷰FROM 절 서브쿼리
한 줄 요약서브쿼리는 WHERE뿐 아니라 SELECT 절(스칼라 서브쿼리)과 FROM 절(인라인 뷰)에도 쓸 수 있다. FROM 절에 쓸 때는 반드시 별칭을 붙여야 한다.
쉽게 말하면FROM 절 서브쿼리는 "임시로 만든 표에서 다시 조회"하는 것이라 인라인 뷰라고 부릅니다. 한 번에 처리하기 복잡한 문제를 ①먼저 이런 표를 만들고 → ②거기서 조건을 건다는 2단계로 쪼갤 수 있게 해 주죠. 이때 만들어진 임시 표에도 이름표(별칭)를 반드시 붙여야 합니다 — 안 붙이면 문법 에러입니다.
SQLscott 서브쿼리(2) 실습
-- SELECT 절 스칼라 서브쿼리 : 행마다 값 하나(부서 이름)를 구해 컬럼처럼 붙인다
SELECT ename,
(SELECT dname FROM dept d WHERE d.deptno = e.deptno) AS dname
FROM emp e;
-- 평균보다 많이 받는 사원 (WHERE 절에서 값 하나를 돌려주는 서브쿼리 = 12번 카드의 단일행 서브쿼리)
SELECT empno, ename, sal
FROM emp
WHERE sal > (SELECT AVG(sal) FROM emp)
ORDER BY sal DESC;
-- 20번 부서 최고 월급보다 많이 받는 사원
SELECT ename, deptno, sal
FROM emp
WHERE sal > (SELECT MAX(sal) FROM emp WHERE deptno = 20);
-- 인라인 뷰 : FROM 절에 서브쿼리 → 별칭(AS a) 필수
SELECT empno, ename, sal
FROM (SELECT empno, ename, sal
FROM emp
WHERE deptno IN (SELECT deptno FROM emp WHERE ename LIKE '%S%')
) AS a
WHERE sal > (SELECT AVG(sal) FROM emp);
실행 결과SELECT 절 스칼라 서브쿼리 · 인라인 뷰 실행 결과 먼저 예측 → 펼쳐서 확인
[첫 번째 쿼리 — SELECT 절 스칼라 서브쿼리] (일부, 14행 중 3행)
+-------+----------+
| ename | dname |
+-------+----------+
| SMITH | RESEARCH |
| ALLEN | SALES |
| WARD | SALES |
+-------+----------+
[마지막 쿼리 — 인라인 뷰] (전체 4행, 순서는 실행 계획에 따라 다를 수 있음)
+-------+-------+------+
| empno | ename | sal |
+-------+-------+------+
| 7566 | JONES | 2975 |
| 7698 | BLAKE | 2850 |
| 7788 | SCOTT | 3000 |
| 7902 | FORD | 3000 |
+-------+-------+------+
이름에 S가 든 사원(SMITH·JONES·SCOTT·ADAMS·JAMES)의 부서 = 20, 30
→ 그중 전체 평균(약 2073)보다 많이 받는 사람
별칭 AS a를 빼고 실행하면 ERROR 1248 (Every derived table must have its own alias)가 납니다 — FROM 절 서브쿼리에 별칭은 선택이 아니라 필수입니다.
수업 파일에서 한 가지 더 발견했습니다. "'SMITH'보다 월급을 많이 받는 사원"을 구하는 01번 문제의 답이 WHERE sal > 800으로 되어 있는데, 800은 SMITH의 월급을 눈으로 확인해 직접 적어 넣은 값입니다. 결과는 맞지만 SMITH의 월급이 바뀌면 틀려지므로, WHERE sal > (SELECT sal FROM emp WHERE ename='SMITH')처럼 서브쿼리로 쓰는 것이 문제의 의도입니다.
23번 정리 — 핵심 정리
스칼라 서브쿼리 — 결과가 값 하나인 서브쿼리. SELECT·WHERE 어디든 값처럼 쓸 수 있다. 보통 "스칼라 서브쿼리"는 SELECT 절에 쓴 것을 가리키고, WHERE 절에서 값 하나를 비교하는 것은 12번 카드의 단일행 서브쿼리와 같은 것이다.
인라인 뷰 — FROM 절에 쓰는 서브쿼리. 조회 결과를 임시 테이블처럼 다룬다.
FROM 절 서브쿼리에는 별칭이 필수다 — 없으면 ERROR 1248(Every derived table must have its own alias).
복잡한 문제는 인라인 뷰로 단계를 나누면 훨씬 읽기 쉬워진다.
값을 직접 적어 넣는(하드코딩) 대신 서브쿼리로 구하면 데이터가 바뀌어도 안전하다.
MAX()를 쓸 수 있으면 > ALL보다 > (SELECT MAX(...))가 읽기 쉽다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
FROM 절 서브쿼리(인라인 뷰)에 별칭이 반드시 필요한 이유는?
그 결과가 이름 없는 임시 테이블이기 때문입니다. 바깥 쿼리가 그 컬럼을 참조하려면 부를 이름이 있어야 합니다. 없으면 ERROR 1248(Every derived table must have its own alias)이 납니다.
인라인 뷰를 쓰면 어떤 문제가 쉬워지나?
한 번에 계산할 수 없는 것을 단계로 쪼갤 수 있습니다. 특히 윈도우 함수(행마다 순위 등을 매기는 함수, 28번·29번 카드) 결과로 조건을 걸 때 필수입니다 — 윈도우 함수는 WHERE에서 못 쓰므로, 인라인 뷰로 순위를 먼저 계산한 뒤 바깥에서 WHERE RN BETWEEN 6 AND 10으로 거릅니다.
💼 실무·코딩테스트에서는코딩테스트에서 '상위 N등'류 문제는 거의 이 패턴입니다 — 인라인 뷰로 순위를 매기고 바깥에서 자릅니다. 실무에서도 복잡한 집계는 인라인 뷰나 CTE로 단계를 나누는 게 읽기 좋아서, 한 방에 쓰기보다 쪼개 쓰는 쪽이 선호됩니다.
24
JOIN — 두 테이블을 이어서 함께 조회하기
JOININNER JOINON카티시안 곱
한 줄 요약A JOIN B ON A.공통컬럼 = B.공통컬럼은 두 테이블을 공통 값으로 연결해 한 줄로 합친다. 서브쿼리가 "한 테이블에서 조건만 빌려오는" 것이라면, JOIN은 양쪽 테이블의 컬럼을 함께 출력할 수 있다.
쉽게 말하면사원 표에는 부서번호만 있고 부서 이름·위치는 부서 표에 있어요. "DALLAS에서 일하는 사원 이름"을 뽑으려면 두 표를 부서번호를 접착제 삼아 나란히 붙여야 합니다. 그게 JOIN입니다. 서브쿼리로도 풀리지만, 양쪽 표의 컬럼을 동시에 보여줘야 한다면 JOIN이 유일한 답입니다.
SQLscott 실습 — 같은 문제를 JOIN과 서브쿼리로
-- JOIN 방식 : 양쪽 테이블 컬럼을 함께 쓸 수 있다
SELECT e.ename, e.deptno, e.job
FROM emp e
JOIN dept d ON e.deptno = d.deptno
WHERE d.loc = 'DALLAS';
-- 서브쿼리 방식 : dept의 컬럼은 출력할 수 없다
SELECT ename, deptno, job
FROM emp
WHERE deptno IN (SELECT deptno FROM dept WHERE loc = 'DALLAS');
-- 코딩테스트 문제 '조건에 맞는 사원 정보 조회하기' : 사원 정보 + 평가 점수 합산
SELECT SUM(G.SCORE) AS SCORE, E.EMP_NO, E.EMP_NAME, E.POSITION, E.EMAIL
FROM HR_EMPLOYEES E
JOIN HR_GRADE G ON E.EMP_NO = G.EMP_NO
WHERE G.YEAR = 2022
GROUP BY E.EMP_NO
ORDER BY SCORE DESC
LIMIT 1;
테이블에 별칭(emp e, dept d)을 붙이면 e.deptno처럼 짧게 쓸 수 있고, 양쪽에 같은 이름의 컬럼이 있을 때 어느 쪽인지 구분할 수 있습니다. ON 조건을 빠뜨리면 두 테이블의 모든 행이 서로 곱해지는 카티시안 곱이 되어 14 × 4 = 56행이 나옵니다 — 옛날식 FROM emp, dept WHERE ... 문법이 위험한 이유이고, JOIN ... ON을 쓰면 연결 조건을 빠뜨리기 어렵습니다.
24번 정리 — 핵심 정리
FROM A JOIN B ON A.컬럼 = B.컬럼 — JOIN은 INNER JOIN의 줄임말로, 양쪽 모두에 있는 행만 나온다.
테이블 별칭(emp e)을 쓰면 짧고, 같은 컬럼명을 구분할 수 있다.
ON을 빠뜨리면 모든 행이 곱해지는 카티시안 곱이 된다 — 쉼표 조인(FROM A, B)의 대표적 사고.
양쪽 테이블의 컬럼을 함께 출력해야 하면 JOIN, 조건만 빌려오면 서브쿼리로도 된다.
한쪽에만 있는 행까지 포함하려면 LEFT JOIN / RIGHT JOIN(외부 조인)을 쓴다.
JOIN 결과에도 GROUP BY·HAVING·ORDER BY를 그대로 적용할 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
서브쿼리로는 못 하고 JOIN으로만 되는 일은 무엇인가?
양쪽 테이블의 컬럼을 함께 출력하는 것입니다. 서브쿼리는 '조건만 빌려오는' 구조라 dept의 부서명을 결과에 넣을 수 없습니다. 사원 이름과 부서 이름을 나란히 보여줘야 한다면 JOIN이 가장 자연스러운 답입니다. (단, WHERE가 아니라 SELECT 목록에 넣는 스칼라 서브쿼리 — SELECT ename, (SELECT dname FROM dept d WHERE d.deptno = e.deptno) FROM emp e — 로도 다른 테이블의 값을 한 칸씩 가져올 수는 있습니다. 가져올 컬럼이 많아지면 JOIN이 훨씬 간단합니다.)
ON 조건을 빠뜨리면 결과가 몇 행이 되나?
두 테이블 행 수의 곱이 됩니다(14 × 4 = 56행). 이걸 카티시안 곱이라 부르고, 대용량에서 이러면 서버가 멈춥니다. JOIN ... ON 문법은 ON을 빠뜨리기 어렵게 만들어 이 사고를 예방합니다.
💼 실무·코딩테스트에서는실무 SQL의 절대다수가 JOIN입니다. 데이터를 정규화해 나눠 저장했기 때문에 의미 있는 조회는 대부분 두 개 이상의 테이블을 봅니다. JOIN을 편하게 읽고 쓰는 능력이 실무 SQL 실력의 핵심이라고 봐도 됩니다. 위 코드 마지막 쿼리의 전체 풀이는 코딩테스트 카드 조건에 맞는 사원 정보 조회하기에 있습니다.
25
ON DUPLICATE KEY UPDATE — 있으면 수정, 없으면 추가
ON DUPLICATE KEYUPSERTPK 중복
한 줄 요약기본키가 겹치는 INSERT는 ERROR 1062로 실패한다. INSERT ... ON DUPLICATE KEY UPDATE를 붙이면 겹칠 때는 UPDATE, 안 겹칠 때는 INSERT가 되어 에러 없이 처리된다.
쉽게 말하면"있으면 고치고, 없으면 새로 넣어라"를 한 문장으로 시키는 것이라 흔히 UPSERT(Update + Insert)라고 부릅니다. 이게 없으면 "먼저 조회해 보고 → 있으면 UPDATE, 없으면 INSERT"를 프로그램에서 직접 분기해야 하는데, 그 사이에 다른 사람이 값을 넣어 버리면 꼬입니다.
SQLsqlDB 실습
-- ① usertbl에서 3개 컬럼만 복사해 새 테이블을 만든다 (CTAS — PK는 복사되지 않는다)
CREATE TABLE membertbl (SELECT USERid, NAME, addr FROM usertbl LIMIT3); -- LIMIT3 함정은 아래 실행 결과 참고
-- ② 그래서 PK를 직접 걸어 준다 — 이게 있어야 아래의 ERROR 1062와 ON DUPLICATE KEY가 동작한다
ALTER TABLE membertbl
ADD CONSTRAINT pk_memberTBL PRIMARY KEY (userID);
-- ③ 그냥 INSERT 하면 PK 중복으로 에러 (뒤 문장들도 실행되지 않는다)
INSERT INTO membertbl VALUES('BBK','비비코','미국');
-- ④ 있으면 수정, 없으면 추가
INSERT INTO membertbl VALUES('BBK','비비코','미국')
ON DUPLICATE KEY UPDATE NAME = '비비코', addr = '미국';
INSERT INTO membertbl VALUES('DJM','동짜몽','일본')
ON DUPLICATE KEY UPDATE NAME = '동짜몽', addr = '일본';
실행 결과직접 실행해 확인한 결과 먼저 예측 → 펼쳐서 확인
[그냥 INSERT]
ERROR 1062 (23000): Duplicate entry 'BBK' for key 'PRIMARY'
[ON DUPLICATE KEY UPDATE 적용 후 — SELECT * FROM membertbl; 총 11행 중 앞 4행(일부)]
+--------+-----------+--------+
| userID | NAME | addr |
+--------+-----------+--------+
| BBK | 비비코 | 미국 | ← 이미 있어서 UPDATE 됨
| DJM | 동짜몽 | 일본 | ← 없어서 INSERT 됨
| EJW | 은지원 | 경북 |
| JKW | 조관우 | 경기 |
| … | … | … | ← 나머지 7행 생략
+--------+-----------+--------+
BBK는 원래 '바비킴/서울'이었는데 '비비코/미국'으로 수정됐고, DJM은 없던 행이라 새로 추가됐습니다. 한 문장이 상황에 따라 다르게 동작한 것입니다.
수업 파일에서 CREATE TABLE membertbl (SELECT ... FROM usertbl LIMIT3);의 LIMIT3는 공백이 빠져 있습니다. 직접 돌려 보니 에러가 나지 않는데 3행 제한도 걸리지 않고 10행 전부가 복사됐습니다. LIMIT3가 LIMIT 절이 아니라 테이블 별칭으로 해석되기 때문입니다(SELECT LIMIT3.ename FROM emp LIMIT3가 실제로 동작합니다). 조용히 틀리는 부류라 특히 주의해야 합니다.
25번 정리 — 핵심 정리
INSERT ... ON DUPLICATE KEY UPDATE 컬럼=값 — PK/UNIQUE가 겹치면 UPDATE, 아니면 INSERT. PK나 UNIQUE가 없는 테이블에서는 겹칠 기준이 없어 그냥 INSERT만 된다(CTAS로 만든 표는 PK가 없으므로 ALTER TABLE ... ADD PRIMARY KEY가 먼저).
겹칠 때 ERROR 1062 Duplicate entry가 나며, 여러 문장을 한 번에 실행하면 그 뒤 문장은 실행되지 않는다.
비슷한 것으로 REPLACE INTO가 있지만, 이건 기존 행을 지우고 새로 넣는 방식이라 나머지 컬럼이 기본값으로 초기화된다.
무시하고 넘어가려면 INSERT IGNORE — 중복이면 그냥 건너뛴다.
LIMIT3처럼 공백을 빠뜨리면 LIMIT 절이 아니라 테이블 별칭으로 읽혀 제한이 걸리지 않는다 — 에러가 없어 더 위험하다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
PK가 겹치는 INSERT를 여러 문장 실행하면 뒤 문장들은 어떻게 되나?
실행되지 않습니다.ERROR 1062에서 멈추기 때문에, 뒤에 정상적인 INSERT가 있어도 들어가지 않습니다. 여러 문장을 한 번에 돌릴 때는 중간에서 멈출 수 있다는 걸 염두에 둬야 합니다.
ON DUPLICATE KEY UPDATE 없이 '있으면 수정, 없으면 추가'를 구현하면 어떤 문제가 있나?
SELECT로 확인 → 분기 → INSERT/UPDATE의 세 단계 사이에 다른 사람이 끼어들 수 있습니다. 확인했을 땐 없었는데 INSERT 직전에 누가 넣어 버리면 에러가 나죠. ON DUPLICATE KEY UPDATE는 이걸 한 문장(원자적)으로 처리해 그 틈을 없앱니다.
💼 실무·코딩테스트에서는이 패턴을 실무에서 UPSERT라고 부르며, 외부 데이터를 주기적으로 동기화할 때(예: 매일 상품 정보 갱신) 거의 항상 씁니다. '동시성 문제를 어떻게 피하는가'를 설명할 수 있으면 좋은 답변이 됩니다.
26
CTE (WITH) — 쿼리에 이름 붙여 읽기 쉽게 만들기
CTEWITH임시결과
한 줄 요약WITH 이름(컬럼들) AS (SELECT ...)로 서브쿼리에 이름을 붙여 본 쿼리에서 테이블처럼 쓴다. 인라인 뷰와 결과는 같지만 훨씬 읽기 쉽다. 단, 그 이름은 바로 뒤 한 문장 안에서만 유효하다.
쉽게 말하면인라인 뷰는 FROM 안에 서브쿼리가 통째로 들어가서 중첩이 깊어질수록 읽기 힘들어집니다. CTE는 그 조각을 위로 빼내 이름을 붙여 두고, 아래 본 쿼리는 그 이름만 부르는 방식이에요. "먼저 이걸 abc라고 하자, 그다음 abc에서 골라내자"처럼 글 읽는 순서대로 쓸 수 있습니다.
SQLsqlDB 실습
-- 인라인 뷰 방식 : 서브쿼리가 FROM 안에 들어가 읽기 번거롭다
-- (수업 파일에는 안쪽 GROUP BY 쿼리만 있다 — 비교하려고 인라인 뷰로 감싼 형태)
SELECT *
FROM (SELECT userid, SUM(price * amount) AS total
FROM buytbl
GROUP BY userid) AS t
ORDER BY total DESC;
-- CTE 방식 : 이름을 먼저 정의하고, 아래에서 그 이름을 쓴다
WITH abc(userid, total) AS
(
SELECT userid, SUM(price * amount)
FROM buytbl
GROUP BY userid
)
SELECT * FROM abc ORDER BY total DESC;
실행 결과실행 결과와 유효 범위 확인 먼저 예측 → 펼쳐서 확인
WITH ... SELECT * FROM abc ORDER BY total DESC; (위 인라인 뷰 방식도 결과가 같다)
+--------+-------+
| userid | total |
+--------+-------+
| BBK | 1920 |
| KBS | 1210 |
| JYP | 200 |
| EJW | 95 |
| SSK | 75 |
+--------+-------+
-- 다음 문장에서 abc를 다시 쓰면?
SELECT * FROM abc ORDER BY total DESC;
ERROR 1146 (42S02): Table 'sqldb.abc' doesn't exist
CTE의 이름은 붙어 있는 그 한 문장 안에서만 살아 있습니다.WITH와 본 SELECT는 세미콜론 없이 이어진 하나의 문장이라 반드시 함께 실행해야 하고, 다음 문장에서 abc를 다시 부르면 "그런 테이블 없다"는 에러가 납니다. 계속 재사용하고 싶다면 VIEW를 만들거나 실제 테이블로 만들어야 합니다.
26번 정리 — 핵심 정리
WITH 이름(컬럼들) AS (SELECT ...) SELECT ... FROM 이름 — 서브쿼리에 이름을 붙인다.
컬럼 목록은 생략할 수 있다(안쪽 SELECT의 컬럼명을 그대로 쓴다). 집계 결과처럼 이름이 없는 컬럼이 있으면 붙여 주는 게 좋다.
유효 범위는 붙어 있는 한 문장뿐 — 다음 문장에서 쓰면 ERROR 1146.
WITH부터 본 SELECT까지가 하나의 문장이므로 중간에 세미콜론을 넣으면 안 되고, 함께 실행해야 한다.
쉼표로 여러 CTE를 정의할 수 있다: WITH a AS (...), b AS (...) SELECT ....
계속 재사용하려면 CREATE VIEW로 뷰를 만든다.
CTE는 MariaDB 10.2 이상, MySQL 8.0 이상에서 쓸 수 있다(그 전 버전은 인라인 뷰로 대신한다).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
CTE와 인라인 뷰는 결과가 같은데 왜 CTE를 쓰나?
읽는 순서 때문입니다. 인라인 뷰는 서브쿼리가 FROM 안에 파묻혀 안쪽부터 거꾸로 읽어야 하지만, CTE는 위에 정의하고 아래에서 이름을 부르는 구조라 글 읽듯 위에서 아래로 읽힙니다. 중첩이 깊어질수록 차이가 커집니다.
CTE로 만든 이름을 다음 문장에서 쓰면 어떻게 되나?
ERROR 1146 Table ... doesn't exist가 납니다. CTE는 붙어 있는 그 한 문장 안에서만 유효하기 때문입니다. WITH부터 본 SELECT까지가 세미콜론 없이 이어진 하나의 문장이라는 점이 핵심입니다. 계속 쓰려면 뷰로 만들어야 합니다.
💼 실무·코딩테스트에서는실무에서 복잡한 리포트 쿼리는 CTE를 여러 개 쌓아 만듭니다 — WITH 일별집계 AS (...), 월별집계 AS (...) SELECT ...처럼요. 단계에 이름이 붙어 있으면 어디가 틀렸는지 찾기도 쉬워집니다.
27
내장 함수 — 문자열 · 숫자 · 날짜와 CASE
UPPERSUBSTRROUNDDATEDIFFCASE
한 줄 요약값을 가공하는 함수들이다. 문자열(UPPER·SUBSTR·CONCAT·CHAR_LENGTH), 숫자(ROUND·FLOOR·MOD), 날짜(DATEDIFF·CURDATE·YEAR)를 조합해 원하는 형태로 만든다.
쉽게 말하면함수는 값을 넣으면 가공된 값이 나오는 기계예요. 여러 대를 이어 붙일 수 있다는 게 핵심입니다. 예를 들어 "첫 글자만 대문자"는 ①첫 글자를 잘라내 SUBSTR(ename,1,1) ②대문자로 UPPER(...) ③나머지는 소문자로 LOWER(SUBSTR(ename,2)) ④둘을 CONCAT으로 붙이기 — 이렇게 기계 네 대를 줄줄이 연결한 결과입니다.
SQLscott 실습 — 내장 함수 문제
-- 첫 글자만 대문자, 나머지는 소문자
SELECT CONCAT(UPPER(SUBSTR(ename,1,1)), LOWER(SUBSTR(ename,2))) AS ename
FROM emp;
-- 2번째 글자부터 3글자 / 글자 수 / 앞뒤 글자만 소문자로
SELECT ename,
SUBSTR(ename, 2, 3) AS 부분,
CHAR_LENGTH(ename) AS 글자수,
LOWER(CONCAT(LEFT(ename,1), RIGHT(ename,1))) AS 앞뒤
FROM emp;
-- 근무일수를 '00년 00개월 00일' 형식으로 (한 달 30일 계산)
SELECT ename,
CONCAT(FLOOR(DATEDIFF(CURDATE(), hiredate) / 365), '년 ',
FLOOR(MOD(DATEDIFF(CURDATE(), hiredate), 365) / 30), '개월 ',
MOD(MOD(DATEDIFF(CURDATE(), hiredate), 365), 30), '일') AS 근무일수
FROM emp;
-- 숫자 함수: 계산만 할 때는 FROM 없이
SET @num = 3456.78; -- @num: 사용자 변수 (같은 접속 동안 값을 기억)
SELECT ROUND(@num, 0) AS r0, ROUND(3456.78, 1) AS r1,
ROUND(3456.789, 2) AS r2, FLOOR(3456.78) AS fl;
-- CASE: 행마다 조건에 따라 다른 값 만들기
SELECT ename, sal,
CASE WHEN sal >= 3000 THEN '고액'
WHEN sal >= 1500 THEN '중간'
ELSE '기본'
END AS 구간
FROM emp
WHERE deptno = 10;
실행 결과실행 결과 먼저 예측 → 펼쳐서 확인
[첫 번째 쿼리 — 첫 글자만 대문자] (일부, 14행 중 3행)
+-------+
| ename |
+-------+
| Smith |
| Allen |
| Ward |
+-------+
[두 번째 쿼리 — 문자열 함수] (일부, 14행 중 3행)
+-------+------+--------+------+
| ename | 부분 | 글자수 | 앞뒤 |
+-------+------+--------+------+
| SMITH | MIT | 5 | sh |
| ALLEN | LLE | 5 | an |
| WARD | ARD | 4 | wd |
+-------+------+--------+------+
[세 번째 쿼리 — 근무일수] (일부, CURDATE()가 2026-09-03인 날 실행한 예)
+-------+-----------------+
| ename | 근무일수 |
+-------+-----------------+
| SMITH | 45년 9개월 1일 |
| ALLEN | 45년 6개월 26일 |
+-------+-----------------+
[네 번째 쿼리 — 숫자 함수]
+------+--------+---------+------+
| r0 | r1 | r2 | fl |
+------+--------+---------+------+
| 3457 | 3456.8 | 3456.79 | 3456 |
+------+--------+---------+------+
[다섯 번째 쿼리 — CASE]
+--------+------+------+
| ename | sal | 구간 |
+--------+------+------+
| CLARK | 2450 | 중간 |
| KING | 5000 | 고액 |
| MILLER | 1300 | 기본 |
+--------+------+------+
SUBSTR의 시작 위치는 0이 아니라 1입니다 — 자바 배열과 달라 헷갈리기 쉽습니다. SUBSTR(ename,2,3)은 "2번째 글자부터 3글자"라서 SMITH → MIT입니다.
근무일수는 CURDATE()(오늘 날짜) 기준이라 실행하는 날마다 값이 달라집니다. 위 예에서 SMITH의 DATEDIFF는 16696일 → 16696 ÷ 365 = 45년, 나머지 271일 → 271 ÷ 30 = 9개월, 나머지 1일입니다.
@num은 사용자 변수입니다. SET @변수 = 값;으로 넣어 두면 같은 접속(세션) 동안 다른 쿼리에서 꺼내 쓸 수 있습니다. 수업 파일의 SELECT ROUND(@num, 0) FROM emp;는 같은 값이 14번 반복 출력됩니다 — FROM emp가 붙어 사원 수만큼 행이 생기기 때문입니다. 단순 계산은 SELECT ROUND(3456.78, 0);처럼 FROM 없이 쓰면 한 줄만 나옵니다.
27번 정리 — 핵심 정리
문자열: UPPER/LOWER(대소문자), SUBSTR(문자열, 시작, 길이), LEFT/RIGHT, CHAR_LENGTH(글자 수), CONCAT(이어 붙이기).
SUBSTR의 시작 위치는 1부터다(자바 인덱스와 다름). 길이를 생략하면 끝까지.
숫자: ROUND(값, 자릿수)(반올림), FLOOR(버림), CEIL(올림), MOD(a,b) 또는 a % b(나머지).
ROUND(값, n)의 n은 남길 소수 자릿수다 — "소수 셋째 자리에서 반올림" = ROUND(값, 2).
CASE로 행마다 다른 값을 만든다: CASE 컬럼 WHEN 값 THEN 결과 ... ELSE 기본값 END.
함수는 겹쳐 쓸 수 있다 — 안쪽부터 차례로 계산된다.
단순 계산은 FROM 없이 SELECT ROUND(3456.78,0);으로 — FROM emp를 붙이면 행 수만큼 반복 출력된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
SUBSTR(ename, 2, 3) 이 뽑아내는 것은 무엇인가? 자바와 다른 점은?
2번째 글자부터 3글자입니다(SMITH → MIT). 자바의 substring(2,3)이 '인덱스 2부터 3 직전까지'인 것과 완전히 다릅니다 — SQL은 시작 위치가 1부터이고 두 번째 인자가 길이입니다. 두 언어를 오갈 때 가장 자주 틀리는 부분입니다.
ROUND(3456.789, 2) 의 결과는? '소수 셋째 자리에서 반올림'은 몇을 쓰나?
3456.79입니다. 두 번째 인자는 남길 소수 자릿수예요. 그래서 '소수 셋째 자리에서 반올림'은 둘째 자리까지 남기라는 뜻이므로 ROUND(값, 2)입니다. 문제 문구와 인자가 한 칸 어긋나 보이는 게 헷갈림의 원인입니다.
💼 실무·코딩테스트에서는코딩테스트 SQL은 반올림 자릿수 지정으로 오답이 갈리는 경우가 많습니다. '소수 N번째 자리에서 반올림' → ROUND(값, N-1)로 바꿔 생각하는 습관을 들이면 실수가 줄어듭니다.
28
윈도우 함수 기초 — OVER()와 PARTITION BY
윈도우함수OVERPARTITION BY
한 줄 요약집계함수() OVER(PARTITION BY 컬럼)은 행을 합치지 않고 그룹별 집계값을 각 행 옆에 붙여 준다. GROUP BY가 여러 행을 한 줄로 접어 버리는 것과 결정적으로 다르다.
쉽게 말하면GROUP BY로 부서별 평균을 구하면 사원 개개인의 이름이 사라집니다 — 부서당 한 줄만 남으니까요. 그런데 "각 사원 옆에 그 사람이 속한 부서의 평균을 같이 보여줘"라고 하면 곤란해집니다. 윈도우 함수는 원본 행을 그대로 두면서 옆 칸에 집계값을 적어 주는 도구예요. 성적표에 내 점수와 반 평균이 나란히 찍히는 것과 같습니다.
SQLscott 실습 — GROUP BY와 윈도우 함수 비교
-- GROUP BY : 부서당 한 줄로 접힌다 (사원 이름을 함께 보여줄 수 없다)
SELECT deptno, ROUND(AVG(sal), 2) AS avg_sal
FROM emp
GROUP BY deptno;
-- 윈도우 함수 : 사원 14명이 그대로 남고, 옆에 부서 평균이 붙는다
SELECT ename, deptno, sal,
ROUND(AVG(sal) OVER(PARTITION BY deptno), 2) AS 부서평균
FROM emp
ORDER BY deptno;
10번 부서 사원 세 명이 각자 한 줄씩 그대로 남아 있고, 옆에 같은 값(2916.67)이 반복해 붙었습니다. GROUP BY deptno였다면 이 세 줄이 한 줄로 합쳐지고 이름은 사라졌을 겁니다. 이것이 "집계 대상 외의 데이터도 함께 보여준다"는 윈도우 함수의 핵심입니다.
28번 정리 — 핵심 정리
함수() OVER(...) 형태로 쓰며, OVER가 붙는 순간 윈도우 함수가 된다.
PARTITION BY = 윈도우 함수판 GROUP BY — 그룹을 나누되 행을 합치지 않는다.
GROUP BY는 행을 접어서 줄이고, 윈도우 함수는 행 수를 그대로 두고 칸을 늘린다.
OVER()를 비워 두면 전체가 한 덩어리가 된다 — AVG(sal) OVER()는 전체 평균.
OVER 안에는 PARTITION BY(그룹)와 ORDER BY(정렬)를 함께 쓸 수 있다.
분류: 집계 함수(SUM·AVG·COUNT…) + 비집계 함수(순위 함수·분석 함수) 모두 OVER와 함께 쓸 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
GROUP BY로는 못 하고 윈도우 함수로만 되는 것은?
개별 행과 집계값을 한 줄에 함께 보여주는 것입니다. GROUP BY는 여러 행을 한 줄로 접어 버려서 사원 이름이 사라지지만, OVER(PARTITION BY deptno)는 14명을 그대로 두고 옆에 부서 평균을 붙입니다.
OVER()를 괄호만 비워 두면 어떤 범위가 되나?
전체가 한 덩어리가 됩니다. AVG(sal) OVER()는 모든 행 옆에 전체 평균을 붙입니다. '내 급여가 전체 평균보다 얼마나 높은가'를 한 줄에서 계산할 때 씁니다.
💼 실무·코딩테스트에서는실무 분석 쿼리에서 '전체 대비 내 비중', '전월 대비 증감', '그룹 내 순위'는 거의 전부 윈도우 함수입니다. GROUP BY만 알면 이런 질문에 서브쿼리를 여러 번 쓰게 되는데, 윈도우 함수를 알면 한 번의 스캔으로 끝납니다.
29
순위 함수 — ROW_NUMBER · RANK · DENSE_RANK · NTILE
ROW_NUMBERRANKDENSE_RANKNTILE
한 줄 요약세 함수 모두 순위를 매기지만 동점자 처리가 다르다. ROW_NUMBER는 무조건 1·2·3, RANK는 공동 순위 뒤를 건너뛰고, DENSE_RANK는 건너뛰지 않는다. NTILE(n)은 n개 그룹으로 나눈다.
쉽게 말하면체육대회 등수 매기기로 생각하면 쉬워요. 2등이 두 명 나왔을 때 — ROW_NUMBER는 "어쨌든 줄은 세워야 하니까" 2등·3등으로 임의로 가릅니다. RANK는 "둘 다 2등, 그러니 다음은 4등"으로 올림픽식입니다. DENSE_RANK는 "둘 다 2등, 다음은 3등"으로 등수가 빠지지 않습니다. 실무에서 페이지 나누기(6~10등만 보기)에는 중복이 없는 ROW_NUMBER를 씁니다.
SQLsqlDB 실습 — 세 함수를 한 번에 비교
SELECT NAME, height,
ROW_NUMBER() OVER(ORDER BY height DESC) AS 'ROW_NUMBER',
RANK() OVER(ORDER BY height DESC) AS 'RANK',
DENSE_RANK() OVER(ORDER BY height DESC) AS 'DENSE_RANK'
FROM usertbl;
-- 지역별로 순위를 새로 시작 (PARTITION BY)
SELECT ROW_NUMBER() OVER(PARTITION BY addr ORDER BY height DESC) AS 지역내순위,
NAME, addr, height
FROM usertbl;
-- NTILE(4) : 키 순으로 줄 세운 뒤 4개 반으로 나눈다
SELECT NTILE(4) OVER(ORDER BY height DESC) AS 반번호, NAME, height
FROM usertbl;
-- 6~10등만 뽑기 : 윈도우 함수는 WHERE에서 못 쓰므로 인라인 뷰로 감싼다
SELECT *
FROM (SELECT *, ROW_NUMBER() OVER(ORDER BY sal DESC) RN FROM emp) a
WHERE RN BETWEEN 6 AND 10;
중복이 없어야 정확히 5개가 나오기 때문입니다. RANK를 쓰면 동점 때문에 6등이 없거나 6등이 세 명일 수 있어 개수가 어긋납니다. 페이지 나누기에는 반드시 중복 없는 번호가 필요합니다.
💼 실무·코딩테스트에서는페이지네이션은 실무에서 가장 흔한 요구사항이고, 코딩테스트에서도 '상위 N등' 문제로 자주 나옵니다. LIMIT만으로 안 되는 경우(그룹별 상위 3개 등)가 오면 ROW_NUMBER + PARTITION BY + 인라인 뷰 조합이 정답입니다.
30
분석 함수 — LEAD · LAG · FIRST_VALUE
LEADLAGFIRST_VALUECUME_DIST
한 줄 요약LAG는 이전 행, LEAD는 다음 행의 값을 현재 행으로 끌어온다. FIRST_VALUE는 그룹의 첫 행 값을 가져온다. 행끼리 비교해야 하는 계산(증감·차이)에 쓴다.
쉽게 말하면표는 원래 한 행씩 따로 계산합니다. "내 키와 바로 앞사람 키의 차이"처럼 옆줄을 참조해야 하는 계산은 원래 SQL로 아주 번거로웠어요. LEAD/LAG는 다음 줄·이전 줄 값을 내 줄로 끌어와 한 행 안에서 뺄셈할 수 있게 해 줍니다. 매출의 전월 대비 증감을 구할 때 가장 많이 쓰는 도구입니다.
SQLsqlDB · scott 실습
-- 다음 사람 / 이전 사람과의 키 차이
SELECT NAME, height AS 키,
height - LEAD(height,1) OVER(ORDER BY height DESC) AS '다음사람과차이',
height - LAG(height,1) OVER(ORDER BY height DESC) AS '이전사람과차이'
FROM usertbl;
-- 지역별 최대 키와 나의 차이 (FIRST_VALUE는 인자가 하나다)
SELECT addr, NAME, height AS 키,
height - FIRST_VALUE(height) OVER(PARTITION BY addr ORDER BY height DESC) AS '지역최대키와차이'
FROM usertbl;
-- 지역 안에서 '나보다 크거나 같은 사람'이 몇 %인지 (CUME_DIST)
SELECT addr, NAME, height AS 키,
CAST(CUME_DIST() OVER(PARTITION BY addr ORDER BY height DESC) * 100 AS INTEGER) AS '누적인원백분율'
FROM usertbl;
-- 입사일 순으로, 바로 앞에 입사한 사원의 급여를 나란히
SELECT ename, hiredate, sal,
LAG(sal,1) OVER(ORDER BY hiredate ASC) AS '이전 사원 급여'
FROM emp;
실행 결과LEAD / LAG · CUME_DIST 실행 결과 먼저 예측 → 펼쳐서 확인
[첫 번째 쿼리 — LEAD / LAG] (전체 10행)
+-----------+------+-----------------------+-----------------------+
| NAME | 키 | 다음사람과차이 | 이전사람과차이 |
+-----------+------+-----------------------+-----------------------+
| 성시경 | 186 | 4 | NULL |
| 이승기 | 182 | 0 | -4 |
| 임재범 | 182 | 5 | 0 |
| 김경호 | 177 | 1 | -5 |
| 바비킴 | 176 | 2 | -1 |
| 은지원 | 174 | 1 | -2 |
| 김범수 | 173 | 1 | -1 |
| 조관우 | 172 | 2 | -1 |
| 윤종신 | 170 | 4 | -2 |
| 조용필 | 166 | NULL | -4 |
+-----------+------+-----------------------+-----------------------+
(이승기·임재범은 키가 같아 두 사람의 순서는 바뀔 수 있음 — 숫자 열은 그대로)
[세 번째 쿼리 — CUME_DIST] (일부, 서울 지역 4행만)
+------+--------+------+----------------+
| addr | NAME | 키 | 누적인원백분율 |
+------+--------+------+----------------+
| 서울 | 성시경 | 186 | 25 |
| 서울 | 이승기 | 182 | 75 |
| 서울 | 임재범 | 182 | 75 |
| 서울 | 바비킴 | 176 | 100 |
+------+--------+------+----------------+
서울 4명 중 '나 이상' 인원 ÷ 4 — 키가 같은 182 두 명은 둘 다 3/4 = 75
맨 첫 행의 LAG와 맨 끝 행의 LEAD는 NULL입니다 — 참조할 이전/다음 행이 없으니까요. 이 NULL을 0으로 보고 싶으면 LAG(height,1,0)처럼 세 번째 인자로 기본값을 줄 수 있습니다.
수업 파일에서 오류를 하나 찾았습니다 — FIRST_VALUE(height,1)처럼 인자를 두 개 주면 ERROR 1064가 납니다. FIRST_VALUE는 인자가 하나뿐이라 FIRST_VALUE(height)로 써야 합니다(LEAD/LAG와 헷갈리기 쉬운 부분입니다).
30번 정리 — 핵심 정리
LAG(컬럼, n) — n행 앞의 값 / LEAD(컬럼, n) — n행 뒤의 값. n을 생략하면 1.
세 번째 인자로 없을 때의 기본값을 줄 수 있다: LAG(sal, 1, 0).
첫 행의 LAG, 마지막 행의 LEAD는 NULL이다.
FIRST_VALUE(컬럼)은 인자가 하나 — 두 개 주면 ERROR 1064. 짝은 LAST_VALUE인데 함정이 있다 — OVER에 ORDER BY가 있으면 기본 범위(프레임)가 '처음 행 ~ 현재 행'이라, LAST_VALUE는 그룹의 마지막 값이 아니라 현재 행(과 같은 값)의 값을 돌려준다. 그룹 끝 값을 원하면 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING을 붙인다.
CUME_DIST()는 누적 백분율(0~1)을 돌려준다 — 100을 곱하면 상위 몇 %인지 나온다.
OVER(ORDER BY ...)의 정렬 기준이 곧 '앞뒤'의 의미를 정한다 — 정렬을 바꾸면 결과가 완전히 달라진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
LAG와 LEAD가 없다면 '전월 대비 증감'을 어떻게 구해야 했을까?
같은 테이블을 한 달씩 어긋나게 셀프 조인해야 했습니다(ON a.월 = b.월 + 1). 훨씬 복잡하고 느리죠. LAG/LEAD는 이 패턴을 한 줄로 만들어 준 도구입니다.
첫 행의 LAG 결과가 NULL인 이유와 처리 방법은?
참조할 이전 행이 없기 때문입니다. 세 번째 인자로 기본값을 줄 수 있어서 LAG(sal, 1, 0)이라고 쓰면 NULL 대신 0이 들어갑니다. 증감률을 계산할 때 NULL 전염을 막으려면 이 처리가 필요합니다.
💼 실무·코딩테스트에서는매출·방문자·재고 같은 시계열 데이터 분석의 기본기입니다. '전일 대비', '전주 대비', '누적 합계' 같은 지표가 전부 이 함수들로 만들어집니다. 데이터 직무 면접에서도 자주 물어봅니다.
31
피벗 — 세로로 쌓인 데이터를 가로로 펼치기
피벗PIVOTSUM(IF())행열전환
한 줄 요약SUM(IF(조건, 값, 0))을 보여줄 칸 수만큼 나열하면 세로로 쌓인 행이 가로 칸으로 펼쳐진다. MySQL에는 PIVOT 문법이 없어 이 방식을 쓴다.
쉽게 말하면엑셀의 피벗 테이블과 같은 일입니다. "김범수 봄 3, 김범수 봄 37, 김범수 여름 14…"처럼 세로로 길게 쌓인 기록을 "김범수 | 봄 40 | 여름 14 | 가을 25 | 겨울 32"처럼 한 줄로 요약된 표로 바꾸는 거죠. 원리는 단순합니다 — 칸마다 "봄이면 금액, 아니면 0"을 더하는 계산을 따로 만드는 것입니다.
SQLsqlDB 실습 5번 — 피벗 연습
DROP TABLE IF EXISTS pivotTest;
CREATE TABLE pivotTest(uName CHAR(3), season CHAR(2), amount INT);
INSERT INTO pivotTest VALUES
('김범수','겨울',10), ('윤종신','여름',15), ('김범수','가을',25), ('김범수','봄',3),
('김범수','봄',37), ('윤종신','겨울',40), ('김범수','여름',14), ('김범수','겨울',22),
('윤종신','여름',64);
-- 피벗 : 계절마다 칸을 따로 만든다
SELECT uName,
SUM(IF(season = '봄', amount, 0)) AS '봄',
SUM(IF(season = '여름', amount, 0)) AS '여름',
SUM(IF(season = '가을', amount, 0)) AS '가을',
SUM(IF(season = '겨울', amount, 0)) AS '겨울',
SUM(amount) AS '합계'
FROM pivotTest
GROUP BY uName;
실행 결과피벗 전후 비교 먼저 예측 → 펼쳐서 확인
[피벗 전 — 세로로 쌓인 형태]
+-----------+--------+--------+
| uName | season | amount |
+-----------+--------+--------+
| 김범수 | 겨울 | 10 |
| 윤종신 | 여름 | 15 |
| 김범수 | 가을 | 25 |
| 김범수 | 봄 | 3 |
| 김범수 | 봄 | 37 |
+-----------+--------+--------+
[피벗 후 — 가로로 펼친 형태]
+-----------+------+--------+--------+--------+--------+
| uName | 봄 | 여름 | 가을 | 겨울 | 합계 |
+-----------+------+--------+--------+--------+--------+
| 김범수 | 40 | 14 | 25 | 32 | 111 |
| 윤종신 | 0 | 79 | 0 | 40 | 119 |
+-----------+------+--------+--------+--------+--------+
김범수의 봄 기록이 3과 37 두 건이었는데 SUM 덕분에 40으로 합산됐습니다. 윤종신은 봄·가을 기록이 없어서 0이 찍혔습니다 — IF의 세 번째 인자를 0 대신 NULL로 두면 빈칸으로 나옵니다.
문자열을 붙여야 할 때는 SUM 대신 MAX(IF(...))를 씁니다. 수업의 SALGRADE 문제가 그 예로, MAX(IF(grade=1, CONCAT(losal,'-',hisal), NULL))처럼 씁니다.
31번 정리 — 핵심 정리
MySQL·MariaDB에는 PIVOT 문법이 없다 — SUM(IF(...))를 칸 수만큼 나열해 흉내 낸다.
IF(조건, 참일때, 거짓일때) — 조건이 맞는 행만 값을 남기고 나머지는 0(또는 NULL)으로 만든다.
SUM으로 감싸는 이유는 같은 칸에 해당하는 행이 여러 개일 수 있어서다.
숫자가 아니라 문자열을 펼칠 때는 MAX(IF(...))를 쓴다(SUM은 문자열을 더할 수 없다).
칸 이름을 미리 알아야 한다 — 계절처럼 값이 고정된 경우에만 쓸 수 있고, 동적으로 늘어나면 프로그램 쪽에서 처리한다.
IF 대신 표준 SQL인 CASE WHEN 조건 THEN 값 ELSE 0 END을 써도 똑같다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
MySQL에 PIVOT 문법이 없는데 어떻게 피벗을 하나?
SUM(IF(조건, 값, 0))을 만들 칸 수만큼 나열합니다. 각 칸이 '이 조건에 맞으면 값, 아니면 0'을 더하는 계산이 되어, 결과적으로 세로 데이터가 가로로 펼쳐집니다.
숫자가 아니라 문자열을 펼칠 때 SUM 대신 무엇을 쓰나? 왜?
MAX(IF(...))를 씁니다. SUM은 문자열을 더할 수 없기 때문입니다. MAX는 그룹에서 NULL이 아닌 값 하나를 골라내는 용도로 쓰이는 셈입니다.
💼 실무·코딩테스트에서는실무에서 엑셀로 내보낼 리포트는 대부분 가로 형태를 요구합니다(월별 컬럼 등). 다만 칸 이름을 미리 알아야 하는 한계가 있어서, 항목이 동적으로 늘어나면 SQL이 아니라 애플리케이션 코드에서 변환하는 게 일반적입니다.
32
JOIN 심화 — 다중 조인 · 셀프 조인 · 비등가 조인
다중조인셀프조인비등가조인BETWEEN 조인
한 줄 요약JOIN은 여러 번 이어 붙일 수 있고, 같은 테이블을 두 번 부를 수도 있으며, 연결 조건이 =가 아니라 BETWEEN 같은 범위여도 된다.
쉽게 말하면지금까지는 두 표를 =로 붙였는데, 실무에서는 세 가지 변형을 자주 씁니다. ① 다중 조인 — 표 세 개를 줄줄이 잇기. ② 셀프 조인 — 같은 표를 두 번 불러 사원과 그 사원의 관리자를 나란히 놓기(둘 다 emp 표에 있으니까요). ③ 비등가 조인 — "월급이 이 구간에 들어가면 이 등급"처럼 범위로 잇기.
SQLscott 실습 12번 — JOIN 이용하기
-- 다중 조인 : 사원 + 부서 + 급여등급 세 테이블
SELECT d.deptno, d.dname, e.ename, e.sal, s.grade
FROM emp e
JOIN dept d ON e.deptno = d.deptno
JOIN salgrade s ON e.sal BETWEEN s.losal AND s.hisal -- 비등가 조인
WHERE d.deptno IN (10, 20)
ORDER BY d.deptno ASC, e.sal DESC;
-- 셀프 조인 : 같은 emp 테이블을 e1(사원) / e2(관리자)로 두 번 부른다
SELECT e1.empno AS 사원번호, e1.ename AS 사원이름,
e2.empno AS 관리자번호, e2.ename AS 관리자이름
FROM emp e1
JOIN emp e2 ON e1.mgr = e2.empno;
실행 결과다중·비등가 조인 · 셀프 조인 실행 결과먼저 예측 → 펼쳐서 확인
[다중 조인 + 비등가 조인 — 10·20번 부서, 월급 구간으로 등급 매기기]
+--------+------------+--------+------+-------+
| deptno | dname | ename | sal | grade |
+--------+------------+--------+------+-------+
| 10 | ACCOUNTING | KING | 5000 | 5 | (3001~9999 → 5등급)
| 10 | ACCOUNTING | CLARK | 2450 | 4 |
| 10 | ACCOUNTING | MILLER | 1300 | 2 | (1201~1400 → 2등급)
| 20 | RESEARCH | SCOTT | 3000 | 4 |
| 20 | RESEARCH | FORD | 3000 | 4 |
| 20 | RESEARCH | JONES | 2975 | 4 |
| 20 | RESEARCH | ADAMS | 1100 | 1 |
| 20 | RESEARCH | SMITH | 800 | 1 | (700~1200 → 1등급)
+--------+------------+--------+------+-------+
(SCOTT·FORD는 월급이 같아 둘의 순서는 바뀔 수 있음)
[셀프 조인 — 사원과 관리자] (일부, 13행 중 3행)
+--------------+--------------+-----------------+-----------------+
| 사원번호 | 사원이름 | 관리자번호 | 관리자이름 |
+--------------+--------------+-----------------+-----------------+
| 7369 | SMITH | 7902 | FORD |
| 7499 | ALLEN | 7698 | BLAKE |
| 7566 | JONES | 7839 | KING |
+--------------+--------------+-----------------+-----------------+
셀프 조인의 핵심은 별칭입니다. 같은 emp 테이블을 e1·e2로 다른 이름을 붙여 서로 다른 표인 것처럼 다룹니다. 별칭이 없으면 어느 쪽 ename인지 구분할 수 없습니다.
이 셀프 조인 결과는 13명만 나옵니다 — 사장 KING은 mgr이 NULL이라 짝이 없어 INNER JOIN에서 빠지기 때문입니다. KING까지 보려면 다음 카드의 LEFT OUTER JOIN이 필요합니다.
32번 정리 — 핵심 정리
다중 조인 — JOIN ... ON ... JOIN ... ON ...으로 계속 이어 붙인다.
셀프 조인 — 같은 테이블을 서로 다른 별칭으로 두 번 불러 조인한다. 사원↔관리자, 카테고리↔상위 카테고리 같은 자기 참조 관계에 쓴다.
비등가 조인 — 연결 조건이 =가 아닌 조인. ON e.sal BETWEEN s.losal AND s.hisal처럼 범위로 잇는다.
조인 조건이 PK–FK 관계일 필요는 없다 — 다만 그 관계로 잇는 것이 가장 흔하다.
테이블을 정규화해서 나눠 놓았기 때문에 조인이 필요해진다. 조인이 많아지면 성능이 떨어지므로 무한정 나누지는 않는다.
INNER JOIN은 짝이 없는 행을 버린다 — NULL이 섞이면 행이 조용히 사라지니 주의.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
셀프 조인에서 별칭이 없으면 왜 안 되나?
같은 테이블을 두 번 부르면 어느 쪽 ename인지 구분할 수 없기 때문입니다. e1·e2로 다른 이름을 붙여야 DB가 서로 다른 표처럼 취급합니다. 별칭이 셀프 조인의 전부라고 해도 됩니다.
사원과 관리자를 셀프 조인하면 왜 14명이 아니라 13명인가?
사장 KING은 mgr이 NULL이라 짝이 없어 INNER JOIN에서 탈락하기 때문입니다. KING까지 보려면 LEFT OUTER JOIN을 써야 합니다. 행이 조용히 줄어드는 전형적인 경우입니다.
💼 실무·코딩테스트에서는셀프 조인은 계층 구조(사원-관리자, 카테고리-상위 카테고리, 댓글-대댓글)를 다룰 때 실무에서 계속 나옵니다. 비등가 조인도 등급·구간표(급여 등급, 배송비 구간, 요금제)를 매길 때 그대로 쓰입니다.
33
OUTER JOIN — 짝이 없는 행까지 살려서 보기
LEFT JOINRIGHT JOINOUTER JOINCOUNT 함정
한 줄 요약INNER JOIN은 양쪽에 짝이 있는 행만 남긴다. LEFT/RIGHT OUTER JOIN은 한쪽 테이블은 전부 남기고, 짝이 없으면 반대쪽을 NULL로 채운다.
쉽게 말하면"부서별 사원 수"를 구할 때 사원이 한 명도 없는 부서는 결과에서 통째로 사라집니다 — INNER JOIN이 짝 없는 행을 버리니까요. 하지만 "사원 0명"이라는 사실 자체가 중요한 정보일 때가 많죠. OUTER JOIN은 기준 쪽 표를 전부 살려 두고 짝이 없는 칸만 NULL로 비웁니다.
SQLscott 실습 Q10 — 같은 문제를 INNER와 OUTER로
-- INNER JOIN : 사원이 없는 부서는 사라진다 (3개 부서만)
SELECT d.dname AS DNAME, d.loc AS LOC,
COUNT(*) AS 'NUMBER OF PEOPLE', ROUND(AVG(sal)) AS SALARY
FROM emp e JOIN dept d ON e.deptno = d.deptno
GROUP BY d.dname, d.loc; -- SELECT에 쓴 일반 컬럼(loc)도 GROUP BY에 넣는다 (수업 파일은 d.dname만)
-- RIGHT OUTER JOIN : 부서를 전부 살린다 (4개 부서)
-- COUNT(*)가 아니라 COUNT(e.ename)이어야 0명이 0으로 나온다!
SELECT d.dname AS DNAME, d.loc AS LOC,
COUNT(e.ename) AS 'NUMBER OF PEOPLE', ROUND(AVG(sal)) AS SALARY
FROM emp e RIGHT OUTER JOIN dept d ON e.deptno = d.deptno
GROUP BY d.dname, d.loc;
실행 결과두 방식의 결정적 차이 먼저 예측 → 펼쳐서 확인
[INNER JOIN — OPERATIONS 부서가 없다]
+------------+----------+------------------+--------+
| DNAME | LOC | NUMBER OF PEOPLE | SALARY |
+------------+----------+------------------+--------+
| ACCOUNTING | NEW YORK | 3 | 2917 |
| RESEARCH | DALLAS | 5 | 2175 |
| SALES | CHICAGO | 6 | 1567 |
+------------+----------+------------------+--------+
[RIGHT OUTER JOIN — OPERATIONS가 0명으로 살아났다]
+------------+----------+------------------+--------+
| ACCOUNTING | NEW YORK | 3 | 2917 |
| OPERATIONS | BOSTON | 0 | NULL |
| RESEARCH | DALLAS | 5 | 2175 |
| SALES | CHICAGO | 6 | 1567 |
+------------+----------+------------------+--------+
[COUNT(*) 를 쓰면 0명이 1명으로 잘못 나온다]
| OPERATIONS | COUNT(*) = 1 | COUNT(e.ename) = 0 |
여기서 COUNT(*)와 COUNT(컬럼)의 차이가 결정적입니다. OUTER JOIN이 OPERATIONS 부서를 NULL로 채운 한 줄로 만들어 놓았기 때문에, COUNT(*)는 그 줄을 세어 1을 돌려줍니다. 반면 COUNT(e.ename)은 NULL을 세지 않아 0이 나옵니다 — 사원 0명이 맞습니다. 수업 파일의 두 번째 답이 COUNT(e.EName)으로 되어 있는 이유입니다.
33번 정리 — 핵심 정리
LEFT OUTER JOIN — 왼쪽(FROM 쪽) 테이블을 전부 남기고, 짝이 없으면 오른쪽을 NULL로.
RIGHT OUTER JOIN — 오른쪽 테이블을 전부 남긴다. OUTER는 생략 가능.
OUTER JOIN 뒤의 COUNT(*)는 함정 — NULL로 채워진 줄도 세므로, 실제 개수는 COUNT(짝쪽_컬럼)으로 세야 한다.
짝이 없는 행은 AVG·SUM도 NULL이 된다(0이 아니다).
"한 건도 없는 것"을 찾으려면 LEFT JOIN 후 WHERE 오른쪽컬럼 IS NULL로 거른다.
FROM A LEFT JOIN B는 FROM B RIGHT JOIN A와 같다 — 보통 LEFT로 통일해 쓰는 편이 읽기 쉽다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
'사원이 0명인 부서'가 INNER JOIN 결과에서 사라지는 이유는?
INNER JOIN은 양쪽에 짝이 있는 행만 남기기 때문입니다. OPERATIONS 부서는 emp에 대응하는 행이 하나도 없어서 조인 결과 자체가 만들어지지 않습니다. 0이라는 정보도 정보인데 통째로 사라지는 것이 문제입니다.
OUTER JOIN 후 COUNT(*)가 0명인 부서를 1로 세는 이유는?
OUTER JOIN이 짝 없는 부서를 NULL로 채운 한 줄로 만들어 두기 때문입니다. COUNT(*)는 '행 개수'라 그 줄도 셉니다. COUNT(e.ename)처럼 짝 쪽 컬럼을 세면 NULL이 빠져 0이 나옵니다.
💼 실무·코딩테스트에서는실무에서 '한 건도 없는 것'을 찾는 요구가 자주 나옵니다 — 주문이 없는 회원, 재고가 없는 상품처럼요. 이때 LEFT JOIN ... WHERE 오른쪽컬럼 IS NULL 패턴이 정석이고, 면접에서도 자주 묻습니다.
34
제약조건 심화 — ALTER · CHECK · 외래키 옵션
ALTER TABLECHECKFOREIGN KEYforeign_key_checks
한 줄 요약제약조건은 CREATE 때 붙이거나 나중에 ALTER TABLE ... ADD CONSTRAINT로 붙인다. CHECK는 값의 범위를 강제하고, foreign_key_checks를 잠시 꺼서 검사를 우회할 수도 있다.
쉽게 말하면제약조건은 데이터베이스가 대신 지켜 주는 규칙이에요. "출생년도는 1900~2020 사이여야 한다"를 CHECK로 걸어 두면, 잘못된 값을 넣으려는 순간 데이터베이스가 거부합니다. 프로그램에서 검사하는 것보다 훨씬 안전하죠 — 프로그램이 여러 개여도 규칙은 한 곳에서만 지켜지니까요. 다만 대량 데이터를 넣을 때는 검사가 느려서 잠시 끄는 기능도 있습니다.
SQLtabledb 실습 — 제약조건 붙이고 우회하기
-- 준비: CREATE DATABASE tabledb; USE tabledb; 후 sqlDB와 같은 구조의
-- usertbl(회원)·buytbl(구매)을 제약조건 없이 만들고 데이터를 넣어 둔 상태
-- (쿼리 #1.sql의 '테이블과 뷰 / 교재실습' 부분. 회원 bbk는 아직 없고 —
-- FK를 걸기 전에 bbk의 구매 행은 지웠다 — 김경호는 birthyear 1871로 들어가 있다)
-- 나중에 붙이기 : PK가 있어야 다른 테이블이 FK로 참조할 수 있다
ALTER TABLE usertbl
ADD CONSTRAINT pk_usertbl_userid PRIMARY KEY (userid);
ALTER TABLE buytbl
ADD CONSTRAINT fk_usertbl_buytbl_userid
FOREIGN KEY (userid) REFERENCES usertbl(userid);
-- CHECK : 값의 범위를 강제한다
-- (수업에서는 이미 범위 밖인 1871 데이터가 있어서 MariaDB의
-- SET check_constraint_checks = 0; 으로 검사를 잠시 끄고 붙인 뒤 다시 1로 켰다)
ALTER TABLE usertbl
ADD CONSTRAINT ck_birthyear
CHECK ( (birthyear >= 1900 AND birthyear <= 2020) AND (birthyear IS NOT NULL) );
-- 검사를 잠시 끄고 넣기 (대량 적재용 — 무결성이 깨질 수 있다)
SET foreign_key_checks = 0;
INSERT INTO buytbl VALUES(NULL, 'bbk', N'모니터', N'전자', 200, 5);
SET foreign_key_checks = 1;
실행 결과제약조건이 실제로 막는지 확인한 결과 먼저 예측 → 펼쳐서 확인
[CHECK — 범위 밖 값]
INSERT INTO usertbl VALUES('ssk', N'성시경', 1871, N'서울', NULL, NULL, 186, '2013-12-12');
→ ERROR 4025 (23000): CONSTRAINT `ck_birthyear` failed for `tabledb`.`usertbl`
INSERT INTO usertbl VALUES('ssk', N'성시경', 1987, N'서울', NULL, NULL, 186, '2013-12-12');
→ 성공
[FOREIGN KEY — 없는 회원(bbk)의 구매, 검사가 켜진 상태]
INSERT INTO buytbl VALUES(NULL, 'bbk', N'모니터', N'전자', 200, 5);
→ ERROR 1452 (23000): Cannot add or update a child row:
a foreign key constraint fails (...)
SET foreign_key_checks = 0; 후 같은 INSERT (위 코드 마지막 부분)
→ 성공 (참조 대상이 없는 채로 들어감)
1871년은 CHECK 범위를 벗어나 ERROR 4025로 거부됐고 1987년은 통과했습니다. ERROR 4025는 MariaDB의 에러 번호이고, MySQL(8.0.16 이상)에서는 같은 위반이 ERROR 3819(Check constraint ... is violated)로 나옵니다. 없는 회원의 구매 기록은 ERROR 1452로 막혔습니다.
SET foreign_key_checks = 0은 조심해서 써야 합니다. 검사를 끈 동안 넣은 데이터는 참조 대상이 없는 채로 남아 무결성이 깨집니다. 수업에서는 실습 편의를 위해 썼지만, 실무에서는 대량 적재 직후 반드시 다시 켜고 정합성을 확인해야 합니다.
34번 정리 — 핵심 정리
제약조건은 CREATE 때 컬럼 옆에 쓰거나 나중에 ALTER TABLE ... ADD CONSTRAINT로 붙인다. FK는 보통 마지막에 붙인다.
참조되는 쪽 컬럼이 PK(또는 UNIQUE)여야 FK를 걸 수 있다 — 부모 테이블의 PK를 먼저 만들 것.
CHECK (조건) — 조건을 만족하지 않는 값을 거부한다. 위반 시 ERROR 4025(MariaDB, MySQL 8.0.16+는 3819). CHECK는 NULL을 통과시키므로 막으려면 IS NOT NULL까지 적는다.
ON DELETE CASCADE / ON UPDATE CASCADE — 부모가 바뀌거나 지워지면 자식도 따라서 바뀌거나 지워진다.
SET foreign_key_checks = 0으로 검사를 잠시 끌 수 있지만, 무결성이 깨진 데이터가 남을 수 있다 — 작업 후 반드시 = 1로 되돌린다.
제약조건에 이름을 붙여 두면(pk_usertbl_userid) 나중에 DROP CONSTRAINT로 지우기 쉽다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
제약조건을 애플리케이션 코드가 아니라 DB에 거는 이유는?
어느 경로로 들어와도 반드시 지켜지기 때문입니다. 프로그램이 여러 개고, 배치 작업이 있고, 사람이 직접 SQL을 칠 수도 있습니다. 코드에만 검사를 두면 한 곳만 빠뜨려도 잘못된 데이터가 들어옵니다. DB 제약은 마지막 방어선입니다.
SET foreign_key_checks = 0 을 쓸 때의 위험은?
검사를 끈 동안 들어간 데이터는 참조 대상이 없는 채로 남습니다. 나중에 조인하면 그 행들이 조용히 사라지거나 집계가 틀어집니다. 편의를 위해 쓰더라도 작업 후 반드시 다시 켜고 정합성을 확인해야 합니다.
💼 실무·코딩테스트에서는실무에서는 대량 데이터 마이그레이션 때 속도 때문에 잠시 끄는 일이 있습니다. 다만 끄고 넣은 뒤에는 고아 행(orphan)을 찾는 검증 쿼리를 반드시 돌립니다. '켜고 끄는 것'보다 '끈 뒤에 검증하는 것'이 실무의 핵심입니다.
35
VIEW — 자주 쓰는 조회를 이름 붙여 저장하기
VIEW가상 테이블보안
한 줄 요약CREATE VIEW 이름 AS SELECT ...는 조회 문장에 이름을 붙여 저장한다. 데이터를 복사해 두는 게 아니라 조회할 때마다 원본을 읽는 가상 테이블이라, 원본이 바뀌면 뷰 결과도 바뀐다.
쉽게 말하면CTE(WITH)가 그 한 문장에서만 쓰는 임시 이름이었다면, 뷰는 데이터베이스에 저장해 두고 계속 쓰는 이름입니다. 복잡한 조인 쿼리를 뷰로 만들어 두면 다음부터는 SELECT * FROM 뷰이름만 하면 되죠. 보안에도 씁니다 — 사원 표에서 급여를 뺀 뷰만 보여 주면, 그 사람은 급여를 볼 수 없습니다.
SQLtabledb 실습
-- tabledb: 34번 카드에서 만든 usertbl·buytbl이 있는 실습 DB
USE tabledb;
-- 뷰 만들기 : userid와 name만 보여 주는 창구
CREATE VIEW v_usertbl
AS SELECT userid, name FROM usertbl;
-- 조회는 일반 테이블과 똑같다
SELECT * FROM v_usertbl;
-- 뷰를 통해 데이터를 넣으면 원본 테이블에 들어간다
-- → 원본의 제약조건(NOT NULL 등)을 반드시 확인해야 한다
관찰 포인트뷰의 성격 (실제 출력이 아니라 직접 실행해 확인할 점) 먼저 예측 → 펼쳐서 확인
뷰는 데이터를 복사해 두지 않는다.
원본 usertbl 이 바뀌면 → v_usertbl 결과도 즉시 바뀐다.
수정이 가능한 뷰 / 불가능한 뷰 (MariaDB·MySQL 기준)
단순 SELECT 로 만든 뷰 → INSERT · UPDATE · DELETE 가능(원본에 반영)
집계함수 · GROUP BY · DISTINCT · UNION → 수정 불가 (조회 전용)
JOIN 으로 만든 뷰 → 제한적: 한 번에 원본 테이블 하나만 바꾸는
UPDATE·INSERT는 되고, DELETE는 안 된다
뷰를 통해 데이터를 넣으면 원본 테이블에 들어갑니다. 그래서 뷰에 보이지 않는 컬럼에 NOT NULL이 걸려 있으면 INSERT가 실패합니다 — 뷰만 보고 작업하면 원본의 제약조건을 놓치기 쉽습니다.
집계 함수·GROUP BY·UNION으로 만든 뷰는 어느 원본 행을 고쳐야 할지 특정할 수 없어 수정이 막힙니다. 수업 파일 주석은 "JOIN 뷰도 수정 불가"라고 적었지만, MariaDB·MySQL은 조인 뷰라도 한 원본 테이블의 컬럼만 바꾸는 UPDATE는 허용합니다(여러 테이블을 한 번에 바꾸거나 DELETE하는 것은 불가). 규칙이 복잡하므로 실무에서 뷰는 대부분 조회용으로 씁니다.
35번 정리 — 핵심 정리
CREATE VIEW 이름 AS SELECT ... — 조회 문장에 이름을 붙여 데이터베이스에 저장한다.
데이터를 복사하지 않는다 — 조회할 때마다 원본을 읽으므로 원본이 바뀌면 결과도 바뀐다.
CTE와의 차이 — CTE(WITH)는 한 문장 안에서만, 뷰는 계속 재사용할 수 있다.
쓰는 이유 셋 — ① 복잡한 쿼리 단순화 ② 보안(민감한 컬럼을 감춘 뷰만 제공) ③ 자주 쓰는 조회 재사용.
뷰를 통한 INSERT·UPDATE는 원본 테이블에 반영된다 — 원본의 제약조건을 확인해야 한다.
집계함수·GROUP BY·DISTINCT·UNION이 들어간 뷰는 수정할 수 없다 — 조회 전용이 된다. JOIN 뷰는 한 원본 테이블만 바꾸는 UPDATE 정도만 허용된다(제한적).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
뷰는 데이터를 복사해 두는가?
아닙니다. 뷰는 저장된 SELECT 문장일 뿐이고, 조회할 때마다 원본을 다시 읽습니다. 그래서 원본이 바뀌면 뷰 결과도 즉시 바뀝니다. 데이터를 실제로 복사해 두는 것은 구체화 뷰(materialized view)인데 MySQL에는 없습니다.
CTE와 뷰의 차이를 한 문장으로 말해보세요.
CTE는 한 문장 안에서만 쓰는 임시 이름, 뷰는 DB에 저장해 계속 쓰는 이름입니다. 같은 쿼리를 여러 곳에서 반복해서 쓴다면 뷰, 이번 한 번만 단계를 쪼개려면 CTE입니다.
💼 실무·코딩테스트에서는실무에서 뷰의 가장 큰 쓰임은 보안입니다. 급여·주민번호 같은 민감한 컬럼을 뺀 뷰만 특정 팀에 열어 주면, 그들은 원본 테이블 자체에 접근할 수 없습니다. 권한 관리와 뷰를 함께 쓰는 게 표준적인 방식입니다.
36
인덱스 — 클러스터형과 보조 인덱스
INDEX클러스터형보조 인덱스PRIMARY KEY
한 줄 요약인덱스는 찾기를 빠르게 하는 색인이다. 클러스터형은 데이터를 그 순서대로 실제 정렬해 두는 것이라 테이블당 하나뿐이고, 보조 인덱스는 위치만 따로 적어 두는 것이라 여러 개 만들 수 있다.
쉽게 말하면영어사전과 책 뒤의 찾아보기에 비유하면 정확합니다. 클러스터형 인덱스는 영어사전 — 따로 색인이 있는 게 아니라 본문 자체가 알파벳 순으로 정렬돼 있어서, 정렬 기준은 하나뿐입니다. 보조 인덱스는 뒤쪽 찾아보기(색인) — "이 단어는 몇 쪽"만 따로 적어 둔 것이라 여러 개 만들 수 있죠. (InnoDB의 보조 인덱스는 "쪽 번호" 대신 그 행의 PK 값을 적어 두고, 그 PK로 클러스터형 인덱스를 한 번 더 찾아갑니다.) PRIMARY KEY를 만들면 자동으로 클러스터형이, UNIQUE를 만들면 보조 인덱스가 생깁니다.
SQLtabledb 실습 — 인덱스가 언제 생기는지
-- tabledb: 34번 카드에서 만든 실습 DB (usertbl이 들어 있다)
USE tabledb;
CREATE TABLE tbl1 (a INT PRIMARY KEY, b INT, c INT);
SHOW INDEX FROM tbl1; -- PRIMARY 하나
CREATE TABLE tbl2 (a INT PRIMARY KEY, b INT UNIQUE, c INT UNIQUE, d INT);
SHOW INDEX FROM tbl2; -- PRIMARY + b + c
-- UNIQUE에 NOT NULL을 함께 걸면 클러스터형이 될 수 있다
CREATE TABLE tbl4 (a INT UNIQUE NOT NULL, b INT UNIQUE, c INT UNIQUE, d INT);
SHOW INDEX FROM tbl4;
-- 클러스터형 인덱스는 실제 정렬 순서를 바꾼다
CREATE TABLE usertbl3 AS SELECT * FROM usertbl ORDER BY RAND(); -- 뒤죽박죽
ALTER TABLE usertbl3 ADD PRIMARY KEY (userid);
SELECT * FROM usertbl3; -- userid 오름차순으로 정렬되어 조회된다
실행 결과SHOW INDEX FROM tbl2 결과 먼저 예측 → 펼쳐서 확인
(주요 컬럼만 — 실제로는 Table·Seq_in_index·Cardinality 등 열이 더 있다)
+------------+----------+-------------+
| Non_unique | Key_name | Column_name |
+------------+----------+-------------+
| 0 | PRIMARY | a |
| 0 | b | b |
| 0 | c | c |
+------------+----------+-------------+
PRIMARY = 클러스터형 (테이블당 1개)
b, c = 보조 인덱스 (여러 개 가능)
가장 인상적인 실험은 마지막 것입니다. ORDER BY RAND()로 일부러 뒤죽박죽 섞어 테이블을 만든 뒤 userid에 PRIMARY KEY를 붙이고 다시 조회하면, ORDER BY를 쓰지 않았는데도 userid 오름차순으로 나옵니다. 클러스터형 인덱스가 데이터의 실제 저장 순서를 바꿔 놓았기 때문입니다.
다만 이건 우연히 그렇게 보이는 것이지 보장되는 순서가 아닙니다 — 정렬이 필요하면 반드시 ORDER BY를 쓰세요.
36번 정리 — 핵심 정리
인덱스는 검색을 빠르게 하지만, INSERT·UPDATE·DELETE는 느려진다(색인도 함께 고쳐야 하므로).
클러스터형 인덱스 — 데이터를 그 순서로 실제 정렬해 저장한다. 테이블당 하나. PRIMARY KEY를 만들면 자동 생성.
보조(비클러스터형) 인덱스 — 값과 그 행의 PK 값(InnoDB 기준)만 따로 적어 둔다. 여러 개 만들 수 있다. UNIQUE나 CREATE INDEX로 생성.
PRIMARY KEY가 없고 UNIQUE NOT NULL인 컬럼이 있으면 그것이 클러스터형이 되기도 한다.
SHOW INDEX FROM 테이블;로 어떤 인덱스가 걸려 있는지 확인한다.
인덱스를 만들었다고 항상 쓰이는 건 아니다 — LIKE '%수'처럼 앞이 열린 검색이나 컬럼을 함수로 감싼 조건에서는 인덱스를 못 쓴다.
정렬이 필요하면 인덱스에 기대지 말고 ORDER BY를 명시할 것.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
인덱스를 많이 만들면 왜 손해가 될 수 있나?
INSERT·UPDATE·DELETE가 느려지기 때문입니다. 데이터가 바뀔 때마다 걸려 있는 모든 인덱스를 함께 고쳐야 하니까요. 책 내용을 고칠 때마다 목차와 색인을 전부 다시 만드는 것과 같습니다. 자주 검색하는 컬럼에만 거는 이유입니다.
인덱스를 만들었는데도 안 쓰이는 대표적인 경우 둘은?
① LIKE '%수'처럼 앞이 열린 패턴 ② WHERE YEAR(hiredate) = 2020처럼 컬럼을 함수로 감싼 조건. 인덱스는 값이 정렬된 순서에 의존하는데, 앞을 모르거나 값을 가공하면 그 순서를 쓸 수 없습니다.
💼 실무·코딩테스트에서는면접 단골 주제입니다. '인덱스를 걸었는데 왜 안 빨라지나요?'라는 질문에 위 두 가지를 설명할 수 있으면 좋습니다. 실무에서는 EXPLAIN으로 실행 계획을 확인해 인덱스가 실제로 쓰이는지 검증합니다 — 추측하지 않고 측정하는 것이 원칙입니다.
37
다대다 관계 — 연결 테이블로 풀어내기
다대다N:M연결 테이블복합 조인
한 줄 요약학생 한 명이 동아리 여러 개에, 동아리 하나에 학생 여러 명이 드는 다대다(N:M) 관계는 테이블 두 개로 표현할 수 없다. 가운데에 연결 테이블을 하나 두고 일대다 두 개로 쪼개야 한다.
쉽게 말하면학생 표에 '가입한 동아리' 칸을 만들면 김범수가 바둑·축구 둘 다 들었을 때 칸 하나에 두 값을 넣어야 합니다. 그렇다고 동아리 표에 학생 칸을 만들어도 똑같은 문제가 생기죠. 그래서 '누가 어디에 들었다'는 사실만 기록하는 표를 따로 만듭니다. 이게 연결 테이블(stdclubtbl)이고, 가입 한 건이 곧 한 행이 됩니다.
SQLsqlDB 실습 6 — 학생과 동아리
CREATE TABLE stdtbl (stdname VARCHAR(10) NOT NULL PRIMARY KEY, addr CHAR(4) NOT NULL);
CREATE TABLE clubtbl (clubname VARCHAR(10) NOT NULL PRIMARY KEY, roomno CHAR(4) NOT NULL);
-- 가운데 연결 테이블 : 양쪽 PK를 각각 FK로 가진다
CREATE TABLE stdclubtbl(
num INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
stdname VARCHAR(10) NOT NULL,
clubname VARCHAR(10) NOT NULL,
FOREIGN KEY (stdname) REFERENCES stdtbl(stdname),
FOREIGN KEY (clubname) REFERENCES clubtbl(clubname));
-- 데이터 (학생 5명 · 동아리 4개 · 가입 6건)
INSERT INTO stdtbl VALUES('김범수','경남'), ('성시경','서울'), ('조용필','경기'),
('은지원','경북'), ('바비킴','서울');
INSERT INTO clubtbl VALUES('수영','101호'), ('바둑','102호'), ('축구','103호'), ('봉사','104호');
INSERT INTO stdclubtbl VALUES(NULL,'김범수','바둑'), (NULL,'김범수','축구'), (NULL,'조용필','축구'),
(NULL,'은지원','축구'), (NULL,'은지원','봉사'), (NULL,'바비킴','봉사');
-- 조회할 때는 연결 테이블을 가운데 두고 양쪽으로 조인한다
SELECT c.clubname, c.roomno, s.stdname, s.addr
FROM stdtbl s
JOIN stdclubtbl sc ON s.stdname = sc.stdname
JOIN clubtbl c ON sc.clubname = c.clubname
ORDER BY c.clubname;
실행 결과실행 결과 먼저 예측 → 펼쳐서 확인
+----------+--------+---------+------+
| clubname | roomno | stdname | addr |
+----------+--------+---------+------+
| 바둑 | 102호 | 김범수 | 경남 |
| 봉사 | 104호 | 은지원 | 경북 |
| 봉사 | 104호 | 바비킴 | 서울 |
| 축구 | 103호 | 김범수 | 경남 |
| 축구 | 103호 | 조용필 | 경기 |
| 축구 | 103호 | 은지원 | 경북 |
+----------+--------+---------+------+ 6행
(ORDER BY가 clubname뿐이라 같은 동아리 안의 학생 순서는 바뀔 수 있음)
김범수가 두 번(바둑·축구), 축구가 세 번 나옵니다. 다대다를 펼치면 가입 건수만큼 행이 생기는 게 정상입니다.
학생 5명 · 동아리 4개인데 결과는 6행뿐입니다 — 동아리에 안 든 성시경과 회원이 없는 수영부가 빠졌기 때문입니다. INNER JOIN이 짝 없는 행을 버리는 성질이 여기서도 그대로 나타납니다. 이 둘까지 보려면 다음 카드의 OUTER JOIN이 필요합니다.
37번 정리 — 핵심 정리
다대다는 연결 테이블(중간 테이블)을 두어 일대다 두 개로 바꾼다.
연결 테이블은 양쪽 테이블의 PK를 각각 FK로 가진다. 그 조합 하나가 '관계 한 건'이다.
조회할 때는 연결 테이블을 가운데 두고 양쪽으로 JOIN한다.
결과 행 수는 관계(가입) 건수만큼이다 — 한쪽 값이 여러 번 반복되는 게 정상이다.
현실의 다대다 예: 학생↔수강과목, 주문↔상품, 게시글↔태그, 배우↔영화.
연결 테이블에 추가 정보(가입일, 수량, 역할)를 넣을 수도 있다 — 오히려 이게 일반적이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
다대다를 테이블 두 개로 만들면 왜 안 되는지 설명해보세요.
한 칸에 여러 값을 넣어야 하기 때문입니다. 학생 표에 '동아리' 칸을 두면 김범수는 '바둑,축구'처럼 넣어야 하는데, 이러면 검색·수정·집계가 전부 불가능해집니다. "축구부원 몇 명?"을 세려면 문자열을 쪼개야 하죠. 한 칸에는 값 하나가 관계형 DB의 대원칙(1정규형)입니다.
연결 테이블의 PK를 num(자동번호) 대신 (stdname, clubname) 조합으로 하면 어떤 이점이 있을까?
같은 학생이 같은 동아리에 두 번 가입하는 것을 DB가 막아 줍니다. 복합 기본키로 두면 조합이 중복될 수 없으니까요. 실무에서는 이 방식(복합 PK)과 자동번호 PK + UNIQUE 제약을 함께 쓰는 방식 둘 다 씁니다.
💼 실무·코딩테스트에서는실무 스키마에서 다대다는 거의 항상 등장합니다 — 주문과 상품(주문상세), 사용자와 권한(사용자권한), 게시글과 태그처럼요. 테이블 설계 문제를 받으면 "이 관계가 다대다인가?"를 먼저 묻고, 맞다면 연결 테이블을 그리는 것이 정석입니다. 면접 설계 문제에서도 이걸 빠뜨리면 감점 요인입니다.
38
OUTER JOIN 실전 — 기준을 바꾸고, 없는 것을 찾고, 양쪽을 다 살리기
LEFT OUTERRIGHT OUTERIS NULLUNIONFULL OUTER
한 줄 요약LEFT와 RIGHT는 어느 쪽을 전부 살릴지만 다르다. 여기에 WHERE 반대쪽 IS NULL을 붙이면 "짝이 없는 것만" 골라낼 수 있고, 두 방향을 UNION으로 합치면 MySQL에 없는 FULL OUTER JOIN을 흉내 낼 수 있다.
쉽게 말하면같은 데이터라도 무엇을 기준으로 세우느냐에 따라 보이는 게 달라집니다. "학생 기준"으로 세우면 동아리 안 든 학생이 보이고, "동아리 기준"으로 세우면 회원 없는 동아리가 보입니다. 둘 다 보고 싶으면 두 결과를 합쳐야 하는데, MySQL에는 그걸 한 번에 하는 문법이 없어서 UNION으로 붙입니다.
SQLsqlDB 실습 7 — 기준을 바꿔 가며
-- ① 학생 기준 : 동아리에 안 든 학생도 보인다
SELECT s.stdname, s.addr, c.clubname, c.roomno
FROM stdtbl s
LEFT OUTER JOIN stdclubtbl sc ON s.stdname = sc.stdname
LEFT OUTER JOIN clubtbl c ON sc.clubname = c.clubname
ORDER BY s.stdname;
-- ② 동아리 기준 : 회원이 없는 동아리도 보인다
-- (수업 파일은 LEFT + RIGHT를 섞어 썼다. 기준 테이블 clubtbl을 FROM에 두면 LEFT만으로 같은 결과)
SELECT c.clubname, c.roomno, s.stdname, s.addr
FROM clubtbl c
LEFT OUTER JOIN stdclubtbl sc ON c.clubname = sc.clubname
LEFT OUTER JOIN stdtbl s ON sc.stdname = s.stdname
ORDER BY c.clubname;
-- ③ 양쪽 다 살리기 : UNION 으로 FULL OUTER JOIN 흉내
-- 두 SELECT의 컬럼 순서를 똑같이 맞춘다 (stdname, addr, clubname, roomno)
SELECT s.stdname, s.addr, c.clubname, c.roomno -- 학생 기준
FROM stdtbl s
LEFT OUTER JOIN stdclubtbl sc ON s.stdname = sc.stdname
LEFT OUTER JOIN clubtbl c ON sc.clubname = c.clubname
UNION
SELECT s.stdname, s.addr, c.clubname, c.roomno -- 동아리 기준
FROM clubtbl c
LEFT OUTER JOIN stdclubtbl sc ON c.clubname = sc.clubname
LEFT OUTER JOIN stdtbl s ON sc.stdname = s.stdname;
-- ④ "없는 것"만 골라내기 : 구매 이력이 없는 회원
SELECT u.userid, u.name
FROM usertbl u
LEFT OUTER JOIN buytbl b ON u.userID = b.userID
WHERE b.prodName IS NULL;
실행 결과기준에 따라 무엇이 보이는지 먼저 예측 → 펼쳐서 확인
[① 학생 기준 LEFT — 7행, 성시경이 NULL로 등장]
| 성시경 | 서울 | NULL | ← 동아리 미가입
[② 동아리 기준 (clubtbl부터 LEFT) — 7행, 수영부가 NULL로 등장]
| 수영 | 101호 | NULL | ← 회원 없음
[③ UNION — 8행, 둘 다 등장 (컬럼: stdname, addr, clubname, roomno)]
| 성시경 | 서울 | NULL | NULL |
| NULL | NULL | 수영 | 101호 |
[④ 구매 이력 없는 회원 — LEFT JOIN + IS NULL]
+--------+-----------+
| userid | name |
+--------+-----------+
| JKW | 조관우 |
| KKH | 김경호 |
| LJB | 임재범 |
| LSG | 이승기 |
| YJS | 윤종신 |
+--------+-----------+
INNER는 6행, LEFT·RIGHT는 각각 7행, UNION은 8행입니다. 행 수만 봐도 무엇이 살아나고 무엇이 빠졌는지 읽힙니다.
④번 패턴이 특히 중요합니다. LEFT JOIN으로 붙인 뒤 WHERE 오른쪽컬럼 IS NULL은 "짝이 없는 것만"을 뜻합니다 — OUTER JOIN이 짝 없는 자리를 NULL로 채워 두었으니, 그 NULL을 조건으로 잡으면 됩니다. "주문 없는 회원", "판매 안 된 상품"이 전부 이 한 패턴입니다.
38번 정리 — 핵심 정리
LEFT와 RIGHT는 어느 쪽을 전부 남길지만 다르다. A LEFT JOIN B ≡ B RIGHT JOIN A.
실무에서는 LEFT JOIN으로 통일해 쓰는 편이 읽기 쉽다 — 기준이 항상 FROM 뒤 테이블이 되니까.
LEFT JOIN + WHERE 오른쪽 IS NULL = "짝이 없는 것만" — 가장 많이 쓰는 관용구.
MySQL에는 FULL OUTER JOIN이 없다 — 학생 기준 결과와 동아리 기준 결과(기준 테이블만 바꾼 두 LEFT JOIN)를 UNION으로 합쳐 흉내 낸다.
UNION은 중복을 제거한다(그래서 겹치는 행이 한 번만 나온다). 중복까지 다 보려면 UNION ALL.
UNION으로 합치려면 양쪽의 컬럼 개수와 순서·타입이 같아야 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
LEFT JOIN한 뒤 WHERE로 오른쪽 컬럼을 IS NULL 검사하면 왜 '짝이 없는 행'만 남는가?
OUTER JOIN이 짝을 못 찾은 자리를 NULL로 채워 두기 때문입니다. 짝이 있는 행은 그 자리에 실제 값이 들어 있으니 IS NULL에 걸리지 않고, 짝이 없어서 NULL이 된 행만 남습니다. NULL이 '데이터가 없다'는 신호로 쓰이는 셈입니다.
UNION과 UNION ALL의 차이, 그리고 성능 면에서 무엇을 골라야 하나?
UNION은 중복을 제거하고 UNION ALL은 그대로 다 붙입니다. 중복 제거는 정렬·비교 과정이 필요해서 UNION ALL이 더 빠릅니다. 중복이 생길 수 없는 상황이라면 UNION ALL을 쓰는 게 실무의 기본입니다.
💼 실무·코딩테스트에서는"한 건도 없는 것을 찾아라"는 실무와 코딩테스트 양쪽의 단골입니다 — 주문 없는 회원, 재고 없는 상품, 로그인 안 한 사용자. NOT IN은 NULL 함정이 있고 NOT EXISTS는 익숙지 않다면, LEFT JOIN ... IS NULL이 가장 안전하고 직관적인 답입니다. 세 가지 방법을 모두 알고 상황에 맞게 고르는 것이 실력입니다.
39
CASCADE — 부모가 바뀌면 자식도 따라가게 하기
ON UPDATE CASCADEON DELETE CASCADE참조 무결성
한 줄 요약외래키에 ON UPDATE CASCADE / ON DELETE CASCADE를 붙이면, 부모의 값이 바뀌거나 지워질 때 자식도 자동으로 따라 바뀌거나 지워진다. 붙이지 않으면 부모를 수정·삭제하는 것 자체가 거부된다.
쉽게 말하면회원 아이디를 바꾸려는데 그 회원의 구매 기록이 이미 쌓여 있는 상황입니다. 아이디만 바꾸면 구매 기록은 없는 회원을 가리키는 미아가 되어 버리죠. 그래서 DB는 기본적으로 수정 자체를 막습니다. CASCADE는 "막지 말고 자식도 같이 바꿔 달라"고 부탁하는 옵션입니다.
SQLtabledb 실습 — 옵션 없이 vs CASCADE
-- tabledb: 34번 카드의 실습 DB. 회원 bbk(바비킴)와 그의 구매 4건이 들어 있는 상태
-- (쿼리 #1.sql '교재실습'에서 넣은 데이터)
-- 기존 외래키를 지우고, CASCADE 옵션을 붙여 다시 만든다
-- DROP에는 실제로 만든 FK 이름을 써야 한다 (34번에서 만든 이름: fk_usertbl_buytbl_userid)
-- ※ 수업 파일은 중간에 FK를 fk_usertbl_buytbl이라는 이름으로 다시 만들어서 그 이름을 지운다.
-- 이름이 다르면 ERROR 1091 (Can't DROP ...; check that it exists)이 난다 — SHOW CREATE TABLE buytbl;로 확인
ALTER TABLE buytbl DROP FOREIGN KEY fk_usertbl_buytbl_userid;
ALTER TABLE buytbl
ADD CONSTRAINT fk_usertbl_buytbl_userid
FOREIGN KEY (userid) REFERENCES usertbl(userid)
ON UPDATE CASCADE
ON DELETE CASCADE;
-- 이제 부모를 바꾸면 자식도 따라 바뀐다
UPDATE usertbl SET userid = 'vvk' WHERE userid = 'bbk';
SELECT userid, prodname FROM buytbl WHERE userid = 'vvk' ORDER BY num;
-- 부모를 지우면 자식도 함께 사라진다
DELETE FROM usertbl WHERE userid = 'vvk';
SELECT COUNT(*) FROM buytbl WHERE userid = 'vvk';
실행 결과옵션이 있을 때와 없을 때를 직접 비교한 결과 먼저 예측 → 펼쳐서 확인
[CASCADE 없이(옵션 없는 FK 상태에서) 부모 아이디 수정]
UPDATE usertbl SET userid = 'vvk' WHERE userid = 'bbk';
→ ERROR 1451 (23000): Cannot delete or update a parent row:
a foreign key constraint fails (...)
[ON UPDATE CASCADE 를 붙인 뒤 같은 UPDATE → 이어서 SELECT]
→ 성공. 자식 테이블이 자동으로 따라 바뀜
+--------+----------+
| userid | prodname |
+--------+----------+
| vvk | 모니터 | ← bbk 였던 구매 4건이 모두 vvk 로
| vvk | 메모리 |
| vvk | 운동화 |
| vvk | 운동화 |
+--------+----------+
[ON DELETE CASCADE 상태에서 부모 삭제 → 이어서 COUNT]
DELETE FROM usertbl WHERE userid = 'vvk';
→ 자식 구매 기록 4건도 함께 삭제됨
+----------+
| COUNT(*) |
+----------+
| 0 |
+----------+
ON DELETE CASCADE는 편리한 만큼 위험합니다. 회원 한 명을 지웠을 뿐인데 그 사람의 구매 기록·리뷰·포인트가 전부 조용히 사라질 수 있습니다. 그래서 실무에서는 ON UPDATE CASCADE는 자주 쓰지만 ON DELETE는 RESTRICT(막기)나 SET NULL을 선호하는 경우가 많습니다.
참고로 앞 카드에서 쓴 SET foreign_key_checks=0은 검사를 끄는 것이라 무결성이 깨진 채로 남지만, CASCADE는 무결성을 지키면서 자동으로 맞춰 준다는 점이 결정적으로 다릅니다.
39번 정리 — 핵심 정리
옵션이 없으면 기본은 RESTRICT — 자식이 참조 중인 부모는 수정·삭제가 거부된다(ERROR 1451).
ON UPDATE CASCADE — 부모 키가 바뀌면 자식의 FK 값도 자동으로 따라 바뀐다.
ON DELETE CASCADE — 부모가 지워지면 자식 행도 함께 삭제된다. 연쇄 삭제라 위험할 수 있다.
ON DELETE SET NULL — 부모가 지워지면 자식의 FK를 NULL로 만든다(해당 컬럼이 NULL 허용이어야 함).
옵션은 외래키를 만들 때 함께 지정한다. 나중에 바꾸려면 DROP FOREIGN KEY 후 다시 ADD해야 한다.
foreign_key_checks=0은 검사를 끄는 것(무결성 깨짐), CASCADE는 무결성을 지키며 자동 반영하는 것 — 전혀 다르다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
CASCADE 옵션이 없을 때 부모 수정이 거부되는 이유를 '미아'라는 말로 설명해보세요.
자식 행이 존재하지 않는 부모를 가리키게 되기 때문입니다. 회원 아이디를 bbk에서 vvk로 바꾸면, bbk로 남아 있는 구매 기록은 가리킬 회원이 없는 미아가 됩니다. 외래키는 '가리키는 대상이 반드시 있다'를 보장하는 규칙이라, 그 약속을 깨는 수정을 미리 막는 것입니다.
실무에서 ON DELETE CASCADE를 조심하라고 하는 이유는?
연쇄 삭제가 어디까지 번질지 눈에 안 보이기 때문입니다. 회원을 지웠는데 구매 기록이, 그 구매의 리뷰가, 리뷰의 댓글이 줄줄이 사라질 수 있습니다. 게다가 되돌리기가 어렵습니다. 그래서 실무에서는 진짜로 지우는 대신 is_deleted 같은 플래그만 바꾸는 소프트 삭제를 쓰는 경우가 많습니다.
💼 실무·코딩테스트에서는실무 스키마 리뷰에서 FK마다 CASCADE 옵션을 어떻게 줄지는 반드시 논의되는 항목입니다. 정답이 하나가 아니라 도메인에 따라 다르기 때문입니다 — 주문상세는 주문이 지워지면 함께 지워지는 게 맞지만(CASCADE), 회원이 지워졌다고 그 사람의 결제 이력까지 지우면 회계상 문제가 생깁니다(RESTRICT나 SET NULL).
40
인덱스 만들고 지우기 — CREATE INDEX와 복합 인덱스
CREATE INDEXUNIQUE INDEX복합 인덱스DROP INDEX
한 줄 요약PK·UNIQUE로 자동 생기는 것 말고도 CREATE INDEX로 직접 인덱스를 만들 수 있다. 컬럼 여러 개를 묶은 복합 인덱스도 가능하며, 필요 없어지면 DROP INDEX로 지운다.
쉽게 말하면앞 카드에서 인덱스가 책의 색인이라고 했죠. 이번엔 그 색인을 직접 만들고 지우는 법입니다. 중요한 건 복합 인덱스의 순서예요. (이름, 출생년도)로 만든 색인은 "이름으로 찾기"와 "이름+출생년도로 찾기"에는 쓰이지만 "출생년도만으로 찾기"에는 쓰이지 않습니다. 전화번호부가 성→이름 순으로 정렬돼 있어서 이름만으로는 못 찾는 것과 같습니다.
SQLsqlDB 실습 — 인덱스 생성·확인·삭제
-- 현재 걸린 인덱스 확인
SHOW INDEX FROM usertbl;
-- 일반 인덱스 (중복 허용)
CREATE INDEX idx_usertbl_addr ON usertbl (addr);
-- 고유 인덱스 : 중복이 있으면 생성 자체가 실패한다
CREATE UNIQUE INDEX idx_usertbl_birthyear ON usertbl (birthyear); -- 실패 (아래 결과)
CREATE UNIQUE INDEX idx_usertbl_name ON usertbl (name); -- 성공
-- 복합 인덱스 : 순서가 중요하다
CREATE INDEX idx_usertbl_name_birthyear ON usertbl (name, birthYear);
-- 만든 뒤 다시 확인 (아래 실행 결과는 이 시점)
SHOW INDEX FROM usertbl;
-- 통계 갱신 후 상태 보기
ANALYZE TABLE usertbl;
SHOW TABLE STATUS LIKE 'usertbl';
-- 삭제
DROP INDEX idx_usertbl_name ON usertbl;
DROP INDEX idx_usertbl_addr ON usertbl;
DROP INDEX idx_usertbl_name_birthyear ON usertbl;
실행 결과실행 결과 먼저 예측 → 펼쳐서 확인
[중복이 있는 컬럼에 UNIQUE 인덱스를 걸면]
CREATE UNIQUE INDEX idx_usertbl_birthyear ON usertbl (birthyear);
→ ERROR 1062 (23000): Duplicate entry '1979' for key 'idx_usertbl_birthyear'
(1979년생이 김범수·성시경 둘이라 실패)
CREATE UNIQUE INDEX idx_usertbl_name ON usertbl (name);
→ 성공 (이름은 중복이 없음)
[인덱스를 만든 뒤 SHOW INDEX FROM usertbl] (주요 컬럼만 — 실제로는 Collation·Cardinality 등 열이 더 있다)
+---------+------------+----------------------------+--------------+-------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name |
+---------+------------+----------------------------+--------------+-------------+
| usertbl | 0 | PRIMARY | 1 | userID |
| usertbl | 0 | idx_usertbl_name | 1 | name |
| usertbl | 1 | idx_usertbl_addr | 1 | addr |
| usertbl | 1 | idx_usertbl_name_birthyear | 1 | name |
| usertbl | 1 | idx_usertbl_name_birthyear | 2 | birthYear |
+---------+------------+----------------------------+--------------+-------------+
Non_unique 1 = 중복 허용(일반) / 0 = 고유
복합 인덱스가 Seq_in_index 1, 2로 두 줄에 걸쳐 표시되는 데 주목하세요. 하나의 인덱스가 컬럼 순서를 갖고 있다는 뜻입니다. 이 순서 때문에 name만으로 검색할 때는 쓰이지만 birthYear만으로는 쓰이지 않습니다(왼쪽부터 채워야 쓸 수 있는 규칙 — 이를 왼쪽 접두사 규칙이라 부릅니다).
UNIQUE 인덱스는 속도만이 아니라 데이터 규칙이기도 합니다. 1979년생이 둘이라 생성이 거부된 것은, 그 컬럼에 중복이 있으면 안 된다는 제약을 걸려 했기 때문입니다.
40번 정리 — 핵심 정리
CREATE INDEX 이름 ON 테이블(컬럼); — 일반 인덱스(중복 허용). CREATE UNIQUE INDEX는 중복도 막는다.
UNIQUE 인덱스는 이미 중복이 있으면 생성 자체가 실패한다(ERROR 1062).
복합 인덱스는 컬럼 순서가 중요하다 — (A, B) 인덱스는 A 단독이나 A+B 검색에는 쓰이지만 B 단독에는 쓰이지 않는다(왼쪽 접두사 규칙).
SHOW INDEX FROM 테이블;로 확인하고, DROP INDEX 이름 ON 테이블;로 지운다.
ANALYZE TABLE은 통계를 갱신해 옵티마이저가 더 나은 실행 계획을 세우게 돕는다.
인덱스는 공짜가 아니다 — 저장 공간을 쓰고 INSERT·UPDATE·DELETE를 느리게 한다. 안 쓰는 인덱스는 지우는 게 낫다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
(name, birthYear) 복합 인덱스가 birthYear 단독 검색에는 쓰이지 않는 이유는?
인덱스가 name으로 먼저 정렬되고 그 안에서 birthYear로 정렬되어 있기 때문입니다. 전화번호부가 성→이름 순인 것과 같아서, 이름만 알고는 어디를 펼칠지 알 수 없습니다. 이걸 왼쪽 접두사 규칙이라 하고, 복합 인덱스를 설계할 때 어떤 컬럼을 앞에 둘지가 핵심인 이유입니다.
인덱스를 만들었는데 조회가 안 빨라졌다면 무엇부터 확인해야 하나?
EXPLAIN으로 실행 계획을 봐야 합니다. 인덱스가 있어도 안 쓰이는 경우가 많거든요 — LIKE '%수'처럼 앞이 열렸거나, WHERE YEAR(날짜)=2020처럼 컬럼을 함수로 감쌌거나, 데이터가 적어 전체를 훑는 게 오히려 빠르다고 옵티마이저가 판단했을 수도 있습니다. 추측하지 말고 측정하는 것이 원칙입니다.
💼 실무·코딩테스트에서는실무 성능 튜닝은 대부분 인덱스 설계입니다. "느린 쿼리를 어떻게 개선하나요?"라는 면접 질문에는 ① EXPLAIN으로 실행 계획 확인 → ② 어느 컬럼에 인덱스가 필요한지 판단 → ③ 복합 인덱스라면 순서 결정 → ④ 적용 후 다시 측정, 순서로 답하면 좋습니다. 인덱스를 추가하는 것보다 안 쓰이는 이유를 찾는 게 먼저입니다.
한 줄 요약제약조건을 지울 때는 참조하는 쪽(자식 FK)부터 지워야 한다. 부모의 PK를 먼저 지우려 하면 참조가 끊어지므로 거부된다. 제약조건 이름은 information_schema에서 조회한다.
쉽게 말하면만드는 순서가 부모 → 자식이었으니 지우는 순서는 그 반대입니다. 건물을 지을 때 기둥을 먼저 세우고 지붕을 얹지만, 철거할 때는 지붕부터 걷어내는 것과 같아요. 자식이 부모를 가리키고 있는 동안에는 부모를 손댈 수 없습니다.
SQLsqlDB 실습 — 제약조건 이름 찾아서 지우기
-- 제약조건 이름부터 확인한다 (이름을 안 주면 숫자가 붙는다)
SELECT TABLE_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME
FROM information_schema.referential_constraints
WHERE CONSTRAINT_SCHEMA = 'sqldb';
-- 1. 자식 테이블의 외래키 먼저 삭제 (이름이 숫자면 백틱 필수)
ALTER TABLE buytbl DROP FOREIGN KEY `1`;
-- 2. 참조가 사라졌으므로 이제 부모의 기본키를 지울 수 있다
ALTER TABLE usertbl DROP PRIMARY KEY;
-- 컬럼 자체를 지우는 것도 ALTER 로 한다
ALTER TABLE usertbl DROP COLUMN birthyear;
실행 결과실행 결과 먼저 예측 → 펼쳐서 확인
[제약조건 이름 조회 — 이름을 안 주면 숫자가 된다]
+------------+-----------------+-----------------------+
| TABLE_NAME | CONSTRAINT_NAME | REFERENCED_TABLE_NAME |
+------------+-----------------+-----------------------+
| buytbl | 1 | usertbl |
| stdclubtbl | 1 | stdtbl |
| stdclubtbl | 2 | clubtbl |
+------------+-----------------+-----------------------+
(stdclubtbl 두 줄은 37번 카드의 학생·동아리 테이블이 같은 sqldb에 남아 있을 때만 나온다)
[자식 FK가 살아 있는 상태에서 부모 PK 삭제 시도]
ALTER TABLE usertbl DROP PRIMARY KEY;
→ ERROR 1025 (HY000): Error on rename of ... (errno: 150
"Foreign key constraint is incorrectly formed")
→ PK 는 그대로 남아 있음 (SHOW KEYS 로 확인)
[자식 FK 를 먼저 지운 뒤 다시 시도]
→ 성공
제약조건에 이름을 주지 않으면 1, 2 같은 숫자가 붙습니다. 이것은 수업에서 쓴 MariaDB의 동작이고, MySQL은 buytbl_ibfk_1처럼 '테이블명_ibfk_번호' 형태로 이름을 지어 줍니다. 이걸 지우려면 DROP FOREIGN KEY `1`처럼 백틱으로 감싸야 합니다 — 숫자로 시작하는 이름은 그냥 쓰면 문법 에러가 나니까요.
그래서 제약조건을 만들 때 이름을 직접 붙이는 습관이 중요합니다. fk_buytbl_userid처럼 지어 두면 나중에 조회할 필요도, 백틱을 쓸 일도 없습니다. 실무 스키마에서 제약조건 이름 규칙을 정해 두는 이유가 이것입니다.
41번 정리 — 핵심 정리
삭제 순서는 생성 순서의 반대 — 자식 FK 먼저, 부모 PK 나중.
참조 중인 부모의 PK를 지우려 하면 ERROR 1025 (errno 150)으로 거부되고 아무것도 바뀌지 않는다.
제약조건에 이름을 안 주면 1, 2 같은 숫자가 붙는다 — 지울 때 백틱 필수.
information_schema.referential_constraints에서 외래키 이름과 참조 대상을 조회할 수 있다.
ALTER TABLE ... DROP FOREIGN KEY 이름 / DROP PRIMARY KEY / DROP COLUMN 컬럼.
만들 때 이름을 직접 붙이자: ADD CONSTRAINT fk_buytbl_userid FOREIGN KEY ... — 나중 작업이 훨씬 쉬워진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
제약조건 삭제 순서가 생성 순서의 반대인 이유를 설명해보세요.
자식이 부모를 가리키고 있기 때문입니다. 만들 때는 가리킬 대상(부모 PK)이 먼저 있어야 했고, 지울 때는 가리키는 손가락(자식 FK)을 먼저 치워야 부모를 건드릴 수 있습니다. 참조 무결성은 '가리키는 대상이 항상 존재한다'는 약속이라, 그 약속이 깨지는 순간을 DB가 허용하지 않습니다.
제약조건에 이름을 직접 붙이는 게 왜 중요한가?
이름을 안 주면 1, 2 같은 숫자가 자동으로 붙어 지울 때 백틱이 필요하고, 무엇보다 어느 제약인지 알아볼 수 없습니다. fk_buytbl_userid처럼 테이블명과 컬럼명이 드러나는 이름을 붙이면 조회 없이도 바로 다룰 수 있고, 스키마 변경 스크립트가 읽기 쉬워집니다.
💼 실무·코딩테스트에서는실무에서 스키마 변경(마이그레이션)은 순서가 전부입니다. 운영 DB에 잘못된 순서로 적용하면 중간에 실패해 절반만 바뀐 상태가 되는데, DDL은 롤백이 안 되므로 수습이 까다롭습니다. 그래서 마이그레이션 스크립트는 테스트 DB에서 순서대로 돌려 보고 적용하며, information_schema로 현재 상태를 먼저 확인하는 것이 기본 절차입니다.
42
EXPLAIN — 인덱스가 실제로 쓰이는지 눈으로 확인하기
EXPLAIN실행 계획typerows
한 줄 요약EXPLAIN을 쿼리 앞에 붙이면 DB가 이 쿼리를 어떻게 실행할 계획인지 보여준다. 인덱스를 만들었다고 쓰이는 게 아니므로, 추측하지 말고 확인해야 한다.
쉽게 말하면인덱스를 걸어 두고 "이제 빨라졌겠지" 하고 넘어가는 것이 가장 흔한 실수입니다. EXPLAIN은 DB에게 "너 이거 어떻게 풀 거야?"라고 미리 물어보는 것이에요. 실제로 실행하지 않고 계획만 알려주니 부담도 없습니다. 여기서 볼 것은 딱 두 개 — type(어떻게 찾을지)과 rows(몇 행을 읽을지)입니다.
SQLindexdb 실습 — 같은 데이터로 세 테이블 만들어 비교
-- 준비: employees 샘플 DB가 설치되어 있어야 한다 (sql_edu_project/employees 폴더)
CREATE DATABASE indexdb;
USE indexdb;
-- employees.employees의 행을 뒤죽박죽 섞어 세 벌 만든다
CREATE TABLE emp SELECT * FROM employees.employees ORDER BY RAND(); -- 인덱스 없음
CREATE TABLE emp_c SELECT * FROM employees.employees ORDER BY RAND(); -- PK(클러스터형)
CREATE TABLE emp_se SELECT * FROM employees.employees ORDER BY RAND(); -- 보조 인덱스
ALTER TABLE emp_c ADD PRIMARY KEY (emp_no);
ALTER TABLE emp_se ADD INDEX idx_emp_se_emp_no (emp_no);
-- 통계를 갱신해야 옵티마이저가 제대로 판단한다
ANALYZE TABLE emp, emp_c, emp_se;
-- 같은 조건인데 실행 계획이 어떻게 다른지 본다
EXPLAIN SELECT * FROM emp WHERE emp_no < 11000;
EXPLAIN SELECT * FROM emp_c WHERE emp_no < 11000;
EXPLAIN SELECT * FROM emp_se WHERE emp_no < 11000;
실행 결과직접 측정한 결과 (측정 당시 테이블은 약 3만 행) 먼저 예측 → 펼쳐서 확인
테이블 type key rows
--------------------------------------------------
emp ALL NULL 30014 ← 전체를 다 훑는다 (= 전체 행 수 수준)
emp_c range PRIMARY 999 ← 훨씬 적게 읽는다
emp_se range idx_emp_se_emp_no 999
※ rows는 '읽을 것으로 예상되는 행 수'(추정치)라 데이터 양에 따라 달라진다.
원본 employees 샘플은 약 30만 행(300,024)이므로, 원본 전체를 복사했다면
emp의 rows는 약 30만 수준으로 나온다.
[저장 순서는 어떻게 달라지나 — SELECT * 로 앞 5행] (emp·emp_se는 RAND()라 실행마다 다름)
emp (인덱스 없음) : 19895, 28870, 14940, 28819, 16084 뒤죽박죽
emp_c (클러스터형) : 10001, 10002, 10003, 10004, 10005 정렬됨
emp_se (보조 인덱스) : 38087, 32393, 39430, 14438, 20825 뒤죽박죽
rows가 전체 행 수 수준(30014)에서 999로 크게 줄었습니다. 이것이 인덱스가 하는 일이고, type이 ALL(전체 훑기)에서 range(범위만 찾기)로 바뀐 것이 그 증거입니다.
저장 순서에서 재미있는 것을 발견했습니다.emp_se를 SELECT emp_no로만 조회하면 정렬돼 보이는데, SELECT emp_no, first_name으로 바꾸면 뒤죽박죽입니다. 앞의 경우는 인덱스만 읽고 끝나서(인덱스는 정렬돼 있으니까) 그렇게 보인 것뿐이죠. 실제 데이터 순서를 바꾸는 것은 클러스터형(PK)뿐입니다.
possible_keys는 쓸 수 있는 후보 인덱스 목록, key는 그중 실제로 고른 인덱스다.
rows는 읽을 것으로 예상되는 행 수다. 이 숫자가 줄면 빨라진 것이다.
key가 NULL이면 인덱스를 안 쓴 것 — 만들어 놨는데 NULL이면 이유를 찾아야 한다.
ANALYZE TABLE로 통계를 갱신해야 옵티마이저가 제대로 판단한다. 데이터를 대량으로 넣은 뒤에는 꼭 해 준다.
정렬돼 보인다고 클러스터형은 아니다 — 인덱스만 읽는 조회(커버링 인덱스)는 그냥 정렬된 인덱스를 순서대로 읽은 것이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
EXPLAIN 결과에서 인덱스가 안 쓰이고 있다는 것을 어떻게 알아채나?
key 열이 NULL이고 type이 ALL이면 인덱스를 안 쓰고 전체를 훑는 것입니다. possible_keys에는 후보가 떠 있는데 key가 비어 있다면, 쓸 수 있었지만 옵티마이저가 안 쓰기로 판단한 것이라 이유를 따져 봐야 합니다.
보조 인덱스만 있는 테이블을 조회했더니 정렬돼 나왔다. 클러스터형이 된 걸까?
아닙니다. 조회한 컬럼이 인덱스에 들어 있는 것뿐이면 DB는 데이터를 안 보고 정렬된 인덱스만 읽고 끝냅니다(커버링 인덱스). 그래서 정렬돼 보이죠. 인덱스에 없는 컬럼을 함께 조회해 보면 실제 저장 순서가 드러납니다.
💼 실무·코딩테스트에서는"느린 쿼리를 어떻게 개선하나요?"에 대한 실무의 첫 대답이 EXPLAIN입니다. 인덱스를 추가하기 전에 지금 계획이 어떤지 보고, 추가한 뒤에 실제로 쓰이는지 확인합니다. 추측으로 인덱스를 늘리는 것이 성능을 오히려 해치는 흔한 경로라, 측정이 먼저입니다.
43
인덱스를 만들어도 안 쓰이는 세 가지 경우
인덱스 미사용함수 감싸기카디널리티힌트
한 줄 요약인덱스가 있어도 ① 조회 범위가 너무 넓거나 ② 컬럼을 연산·함수로 감싸거나 ③ 값의 종류가 너무 적으면 옵티마이저가 인덱스를 포기하고 전체를 훑는다.
쉽게 말하면인덱스는 책 뒤의 찾아보기인데, 세 상황에서는 쓸모가 없어집니다. ① 책의 90%를 읽어야 한다면 찾아보기를 왔다 갔다 하느니 그냥 처음부터 읽는 게 빠릅니다. ② "단어를 거꾸로 뒤집은 것"을 찾으라면 찾아보기가 소용없죠 — 색인은 원래 단어로 정렬돼 있으니까요. ③ 남/여처럼 두 종류뿐이면 찾아봐야 절반이라 의미가 없습니다.
SQLindexdb 실습 — 세 가지 경우를 직접 확인
-- indexdb: 42번 카드에서 만든 emp(인덱스 없음)·emp_c(PK)·emp_se(보조 인덱스)
-- ※ 아래 숫자(39000, 20000)는 약 3만 행 데이터에 맞춘 값이다.
-- 수업 파일은 원본 employees(약 30만 행) 기준이라 emp_no < 400000, emp_no = 100000을 썼다.
-- ① 조회 범위가 넓으면 인덱스를 포기한다
EXPLAIN SELECT * FROM emp_se WHERE emp_no < 11000; -- 전체의 3%
EXPLAIN SELECT * FROM emp_se WHERE emp_no < 39000; -- 전체의 96%
-- ② 컬럼을 연산으로 감싸면 못 쓴다 (우변을 계산하는 것은 괜찮다)
EXPLAIN SELECT * FROM emp_c WHERE emp_no = 20000; -- 그대로
EXPLAIN SELECT * FROM emp_c WHERE emp_no*1 = 20000; -- 좌변 연산 (x)
EXPLAIN SELECT * FROM emp_c WHERE emp_no = 20000/1; -- 우변 연산 (o)
-- ③ 값의 종류가 적으면 인덱스가 있어도 안 쓴다
ALTER TABLE emp ADD INDEX idx_emp_gender (gender);
EXPLAIN SELECT * FROM emp WHERE gender = 'M';
-- 힌트로 권하거나(USE) 못 쓰게 할 수 있다(IGNORE) — 진짜 강제는 FORCE INDEX
EXPLAIN SELECT * FROM emp_c USE INDEX(PRIMARY) WHERE emp_no < 11000;
EXPLAIN SELECT * FROM emp_c IGNORE INDEX(PRIMARY) WHERE emp_no < 11000;
실행 결과직접 측정한 결과 (측정 당시 약 3만 행 — rows는 추정치라 데이터 양에 따라 달라짐)먼저 예측 → 펼쳐서 확인
조건 type key rows
------------------------------------------------------------
emp_no < 11000 (3%) range 있음 999 ← 인덱스 사용
emp_no < 39000 (96%) ALL (안 씀) 30014 ← 포기
emp_no = 20000 const PRIMARY 1 ← 사용
emp_no*1 = 20000 ALL (안 씀) 29970 ← 못 씀
emp_no = 20000/1 const PRIMARY 1 ← 사용
gender = 'M' (값 2종류) ALL (안 씀) 30014 ← 포기
USE INDEX(PRIMARY) range PRIMARY 999 ← 이 인덱스만 후보로 (힌트)
IGNORE INDEX(PRIMARY) ALL (안 씀) 29970 ← 강제 금지
②번이 특히 중요합니다.emp_no*1 = 20000과 emp_no = 20000/1은 수학적으로 같은 뜻인데 결과가 정반대입니다. 인덱스는 emp_no 값 그대로 정렬돼 있어서, 좌변을 건드리면 정렬된 순서를 쓸 수 없게 됩니다. 우변은 아무리 계산해도 결국 상수 하나가 되니 괜찮고요.
같은 이유로 WHERE YEAR(hire_date) = 2020도 인덱스를 못 씁니다. 컬럼은 왼쪽에 맨몸으로 두는 것이 원칙입니다.
43번 정리 — 핵심 정리
① 조회 범위가 넓으면 인덱스를 왔다 갔다 하는 비용이 더 커서 옵티마이저가 일부러 전체 훑기를 고릅니다(대략 20~30%가 분기점).
② 컬럼을 연산·함수로 감싸면 못 씁니다 — col*1, YEAR(col), SUBSTR(col,1,3) 전부 해당.
우변을 계산하는 것은 괜찮습니다 — col = 20000/1은 상수로 정리되므로 인덱스를 씁니다.
③ 값의 종류가 적으면(성별·Y/N) 인덱스 효과가 없습니다. 이를 카디널리티가 낮다고 합니다.
USE INDEX(이름)는 "이 인덱스 중에서 골라라"는 힌트라 옵티마이저가 전체 스캔이 낫다고 보면 여전히 안 쓸 수 있고, 진짜로 강제하려면 FORCE INDEX(이름)(전체 스캔은 정말 불가능할 때만), 금지하려면 IGNORE INDEX(이름)를 쓴다. 대부분 옵티마이저 판단이 옳으니 진단용으로 쓴다.
날짜 조건은 함수 대신 범위로 쓰면 인덱스를 살릴 수 있다 — YEAR(d)=2020 대신 d >= '2020-01-01' AND d < '2021-01-01'.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
emp_no*1 = 20000 은 안 되고 emp_no = 20000/1 은 되는 이유를 설명해보세요.
인덱스는 emp_no의 값 그대로 정렬해 둔 것이기 때문입니다. 좌변을 *1로 감싸면 가공된 값을 찾아야 하는데 그런 순서로 정렬된 색인은 없죠. 반면 우변 20000/1은 실행 전에 20000이라는 상수로 정리되므로 원래 값으로 찾는 것과 같습니다.
'WHERE YEAR(hire_date) = 2020' 을 인덱스가 쓰이게 고쳐보세요.
WHERE hire_date >= '2020-01-01' AND hire_date < '2021-01-01'로 바꿉니다. 컬럼을 맨몸으로 두고 범위 조건으로 표현하면 정렬된 인덱스를 그대로 탈 수 있습니다. 실무 튜닝에서 가장 자주 쓰는 교정입니다.
💼 실무·코딩테스트에서는이 세 가지는 면접에서 "인덱스를 걸었는데 왜 안 빨라지나요?"에 대한 모범 답안 그대로입니다. 실무에서는 특히 ②번(함수로 감싸기)이 자주 발견되는데, 날짜 컬럼에 DATE()·YEAR()를 씌운 조건이 대표적입니다. 범위 조건으로 바꾸는 것만으로 극적으로 빨라지는 경우가 많습니다.
44
스토어드 프로시저 — 자주 쓰는 작업을 DB에 저장해 두기
PROCEDURECALLDELIMITERIN 매개변수
한 줄 요약여러 문장으로 이루어진 작업에 이름을 붙여 DB에 저장해 두고 CALL 이름()으로 실행한다. 매개변수를 받을 수 있어 같은 로직을 값만 바꿔 재사용한다.
쉽게 말하면SQL로 쓰는 함수라고 보면 됩니다. 지금까지는 쿼리를 매번 통째로 다시 쳤는데, 프로시저로 만들어 두면 CALL getDept_emp(10) 한 줄이면 되죠. 자바의 메서드와 발상이 같습니다 — 이름을 붙이고, 매개변수를 받고, 안에서 여러 일을 한다는 점에서요. 다만 실행 위치가 애플리케이션이 아니라 DB 서버라는 점이 다릅니다.
SQLscott 연습문제 13 — 부서별 사원 조회 프로시저
DROP PROCEDURE IF EXISTS getDept_emp;
DELIMITER $$
CREATE PROCEDURE getDept_emp(IN p_deptno INT)
BEGIN
-- 부서 존재 여부를 담을 변수 (반드시 BEGIN 바로 뒤에 선언)
DECLARE v_cnt INT DEFAULT 0;
SELECT COUNT(*) INTO v_cnt
FROM dept
WHERE deptno = p_deptno;
IF v_cnt > 0 THEN
SELECT ename, job, sal FROM emp WHERE deptno = p_deptno;
ELSE
SELECT '해당 부서가 없습니다.' AS '메시지';
END IF;
END $$
DELIMITER ;
CALL getDept_emp(10); -- 있는 부서
CALL getDept_emp(99); -- 없는 부서
실행 결과실행 결과 먼저 예측 → 펼쳐서 확인
CALL getDept_emp(10);
+--------+-----------+------+
| ename | job | sal |
+--------+-----------+------+
| CLARK | MANAGER | 2450 |
| KING | PRESIDENT | 5000 |
| MILLER | CLERK | 1300 |
+--------+-----------+------+
CALL getDept_emp(99);
+--------------------------------+
| 메시지 |
+--------------------------------+
| 해당 부서가 없습니다. |
+--------------------------------+
[DELIMITER 없이 만들면?]
CREATE PROCEDURE bad() BEGIN SELECT 1; SELECT 2; END;
→ ERROR 1064 (42000): You have an error in your SQL syntax
DELIMITER가 왜 필요한지가 이 실습의 핵심입니다. 프로시저 본문 안에도 세미콜론이 들어가는데, 여러 문장을 서버로 보내기 전에 세미콜론 기준으로 문장을 자르는 것은 클라이언트(HeidiSQL, mysql 명령줄)입니다. 그래서 그대로 두면 첫 세미콜론에서 잘린 미완성 문장이 서버로 가 버립니다. DELIMITER $$로 문장 끝 기호를 잠시 바꿔 두고, 다 만든 뒤 DELIMITER ;로 되돌립니다 — DELIMITER 자체도 SQL이 아니라 클라이언트에게 주는 명령이라 서버로는 전달되지 않습니다(JDBC로 프로시저를 만들 때는 필요 없습니다). 실제로 빼고 만들어 보니 ERROR 1064가 났습니다.
DECLARE는 BEGIN 바로 뒤에서만 쓸 수 있습니다. 중간에 선언하려 하면 문법 에러입니다.
44번 정리 — 핵심 정리
CREATE PROCEDURE 이름(IN 매개변수 타입) BEGIN ... END 로 만들고 CALL 이름(값)으로 실행한다.
DELIMITER $$로 문장 끝 기호를 바꿔 두고 만든 뒤 DELIMITER ;로 되돌린다 — 빼면 ERROR 1064.
DECLARE는 BEGIN 바로 뒤에서만 쓸 수 있다.
SELECT ... INTO 변수로 조회 결과를 변수에 담는다.
매개변수 종류: IN(받기, 기본) · OUT(돌려주기) · INOUT(둘 다).
DROP PROCEDURE IF EXISTS 이름;을 앞에 두면 다시 만들 때 충돌이 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DELIMITER가 필요한 이유를 설명해보세요.
프로시저 본문 안에도 세미콜론이 들어가기 때문입니다. 클라이언트(HeidiSQL·mysql 명령줄)가 세미콜론을 문장의 끝으로 보고 잘라서 서버에 보내므로, 그대로 두면 BEGIN 다음 첫 세미콜론에서 잘라 버려 미완성 문장이 됩니다. 그래서 끝 기호를 잠시 $$ 같은 것으로 바꿔 두고, 다 만든 뒤 되돌립니다.
프로시저를 쓰면 무엇이 좋고, 반대로 무엇이 불편해질까?
좋은 점 — 로직이 DB 안에 있어 네트워크 왕복이 줄고, 여러 애플리케이션이 같은 로직을 공유합니다. 불편한 점 — 코드가 애플리케이션 밖에 있어 버전 관리·테스트·디버깅이 어렵고, 특정 DB에 종속됩니다. 그래서 요즘은 비즈니스 로직은 애플리케이션에 두는 쪽이 우세합니다.
💼 실무·코딩테스트에서는프로시저는 레거시 시스템에서 아주 많이 만나게 됩니다(특히 금융·공공). 새로 만드는 서비스는 애플리케이션에 로직을 두는 편이지만, 대량 배치 처리처럼 데이터를 많이 오가야 하는 작업은 지금도 프로시저가 유리합니다. 왜 그렇게 갈리는지를 설명할 수 있으면 좋습니다.
45
프로시저 제어문 — IF · CASE · WHILE
IFCASEWHILEDECLARESET
한 줄 요약프로시저 안에서는 변수를 선언하고 조건 분기와 반복을 쓸 수 있다. SQL이 "무엇을 원하는지"만 말하는 언어인데, 프로시저 안에서만은 절차적으로 쓸 수 있는 셈이다.
쉽게 말하면SQL은 원래 반복문도 조건문도 없는 언어입니다. "조건에 맞는 행을 다 가져와"라고 하면 DB가 알아서 훑으니까요. 그런데 "1부터 100까지 더해라" 같은 절차적인 일은 그렇게 표현할 수 없죠. 프로시저 안에서는 자바처럼 변수를 만들고 반복문을 돌릴 수 있습니다 — SQL 속의 작은 프로그래밍 언어인 셈입니다.
SQLsqlDB 실습 — 변수·반복·분기
-- 재실행용: 이미 있으면 지우고 다시 만든다
-- (이 두 줄이 없으면 두 번째 실행부터 CREATE에서 'already exists' 에러가 난다)
DROP PROCEDURE IF EXISTS whileProc;
DROP PROCEDURE IF EXISTS caseProc;
DELIMITER $$
-- WHILE : 1부터 100까지 더하기
CREATE PROCEDURE whileProc()
BEGIN
DECLARE i INT DEFAULT 1;
DECLARE total INT DEFAULT 0;
WHILE i <= 100 DO
SET total = total + i;
SET i = i + 1;
END WHILE;
SELECT total AS '1~100 합계';
END $$
-- CASE : 점수로 학점 구하기
CREATE PROCEDURE caseProc(IN p_score INT)
BEGIN
DECLARE v_grade CHAR(1);
CASE
WHEN p_score >= 90 THEN SET v_grade = 'A';
WHEN p_score >= 80 THEN SET v_grade = 'B';
ELSE SET v_grade = 'F';
END CASE;
SELECT p_score AS 점수, v_grade AS 학점;
END $$
DELIMITER ;
CALL whileProc();
CALL caseProc(85);
여기서 쓰는 CASE는 SELECT 안에서 쓰던 것과 다릅니다. SELECT의 CASE는 값을 만들어 내는 식이라 END로 끝나지만, 프로시저의 CASE는 실행 흐름을 가르는 문장이라 END CASE로 끝나고 안에 SET 같은 명령이 들어갑니다. 이름이 같아 헷갈리기 쉬운 부분입니다.
변수 이름 앞에 v_, 매개변수에 p_를 붙이는 것은 문법이 아니라 관례입니다. 컬럼명과 헷갈리는 것을 막아 주므로 지키는 편이 좋습니다.
45번 정리 — 핵심 정리
DECLARE 변수 타입 DEFAULT 값; — BEGIN 바로 뒤에서만 선언할 수 있다.
값 대입은 SET 변수 = 값; 또는 SELECT ... INTO 변수.
IF 조건 THEN ... ELSEIF ... ELSE ... END IF; — ELSEIF는 붙여 쓴다(ELSE IF 아님).
CASE WHEN ... THEN ... END CASE; — SELECT 안의 CASE ... END(값을 만드는 식)와 다른 것이다.
WHILE 조건 DO ... END WHILE; — 그 외에 REPEAT ... UNTIL, LOOP ... LEAVE도 있다.
관례상 변수는 v_, 매개변수는 p_를 붙여 컬럼명과 구분한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
SELECT 안의 CASE와 프로시저 안의 CASE는 무엇이 다른가?
SELECT의 CASE는 값을 만들어 내는 '식'이라 END로 끝나고 값이 들어갈 자리에만 씁니다. 프로시저의 CASE는 흐름을 가르는 '문장'이라 END CASE로 끝나고 안에 SET 같은 명령을 넣습니다. 이름만 같고 쓰임이 다릅니다.
SQL에 원래 반복문이 없는데도 잘 돌아가는 이유는?
집합 단위로 처리하기 때문입니다. "조건에 맞는 행을 전부"라고 말하면 DB가 알아서 훑으므로 내가 한 행씩 돌 이유가 없죠. 그래서 프로시저에서도 반복문으로 한 행씩 처리하는 것은 대개 느립니다 — 가능하면 한 문장의 집합 연산으로 푸는 것이 원칙입니다.
💼 실무·코딩테스트에서는실무에서 프로시저 안의 반복문은 조심해서 씁니다. 한 행씩 도는 방식(커서 반복)은 집합 연산 한 문장보다 수십 배 느린 경우가 많거든요. "반복문을 쓰기 전에 한 문장으로 표현할 수 없는지"를 먼저 묻는 것이 SQL다운 사고방식입니다.
46
스토어드 함수 — 값을 돌려주는 나만의 함수
FUNCTIONRETURNSDETERMINISTIC프로시저와 차이
한 줄 요약CREATE FUNCTION으로 만든 함수는 값 하나를 돌려주며, ROUND()처럼 SELECT 안에서 값처럼 쓸 수 있다. 프로시저는 CALL로만 실행되고 SELECT 안에서는 쓸 수 없다.
쉽게 말하면프로시저와 함수는 비슷해 보이지만 쓰는 자리가 완전히 다릅니다. 프로시저는 "이 작업을 해라"고 시키는 것(CALL)이고, 함수는 "이 값을 계산해 줘" 하고 결과를 받아 쓰는 것입니다. 그래서 함수는 SELECT ename, myFunc(sal) FROM emp처럼 컬럼 자리에 끼워 넣을 수 있죠 — 회사마다 다른 계산 규칙(수수료·등급)을 함수로 만들어 두면 편합니다.
SQLsqlDB 실습 — 함수 만들고 쓰기
DELIMITER $$
CREATE FUNCTION userFunc(VALUE1 INT, VALUE2 INT)
RETURNS INT -- 무엇을 돌려줄지 반드시 선언
DETERMINISTIC -- 같은 입력이면 같은 결과라는 '표시' (수업 파일에는 없는 줄)
BEGIN
RETURN VALUE1 + VALUE2;
END $$
DELIMITER ;
-- 내장 함수처럼 값 자리에 그대로 쓴다
SELECT userFunc(100, 200);
-- 컬럼과 섞어 쓸 수도 있다
SELECT ename, userFunc(sal, 500) AS '500 더한 급여'
FROM scott.emp;
-- 결과를 변수에 담기
-- (@age는 수업 파일의 이름 그대로일 뿐 나이와 상관없다 — 담기는 값은 합계 300.
-- 실제로는 @total처럼 뜻이 드러나는 이름이 좋다)
SELECT userFunc(100, 200) INTO @age;
SELECT @age;
실행 결과실행 결과와 프로시저와의 차이 먼저 예측 → 펼쳐서 확인
SELECT userFunc(100, 200); → 300
SELECT ename, userFunc(sal, 500) AS '500 더한 급여' FROM scott.emp; (일부, 14행 중 앞 3행)
+-------+-------------------+
| ename | 500 더한 급여 |
+-------+-------------------+
| SMITH | 1300 |
| ALLEN | 2100 |
| WARD | 1750 |
+-------+-------------------+
[프로시저를 SELECT 안에서 부르면?]
SELECT whileProc();
→ ERROR 1305 (42000): FUNCTION sqldb.whileProc does not exist
프로시저를 SELECT 안에서 부르니 "그런 함수가 없다"고 합니다. DB가 SELECT 안의 이름을 함수로만 찾기 때문이죠 — 프로시저가 분명히 있는데도 못 찾는 것처럼 보이는 이유입니다. 이 에러 메시지를 만나면 "함수와 프로시저를 헷갈렸구나"로 읽으면 됩니다.
DETERMINISTIC은 "같은 입력에는 항상 같은 결과"라는 약속(표시)입니다. DB가 정말 그런지 검사하지는 않고, 결과를 캐시해 주는 기능도 아닙니다. 주된 효과는 바이너리 로그(binlog)·복제와 관련된 것으로, binlog가 켜진 서버에서는 DETERMINISTIC(또는 NO SQL·READS SQL DATA) 표시가 없으면 설정(log_bin_trust_function_creators)에 따라 함수 생성이 거부됩니다. 수업처럼 binlog가 꺼진 기본 설치에서는 붙이지 않아도 만들어집니다.
46번 정리 — 핵심 정리
CREATE FUNCTION 이름(매개변수) RETURNS 타입 BEGIN ... RETURN 값; END.
함수는 SELECT 안에서 값처럼 쓰고, 프로시저는 CALL로만 실행한다.
함수는 값 하나만 돌려준다. 여러 행을 결과로 내려면 프로시저를 써야 한다.
RETURNS(선언, s 있음)와 RETURN(실제 반환, s 없음)은 다른 키워드다 — 자주 틀린다.
DETERMINISTIC은 같은 입력에 같은 결과라는 표시(DB가 검사하거나 캐시하지는 않음). binlog가 켜진 서버에서는 안 붙이면 생성이 거부될 수 있다.
SELECT 함수() INTO @변수로 결과를 세션 변수에 담아 재사용할 수 있다.
프로시저를 SELECT에서 부르면 ERROR 1305 FUNCTION ... does not exist — 함수와 헷갈린 것이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
함수와 프로시저를 한 문장으로 구분해보세요.
함수는 값을 돌려받아 쓰는 것(SELECT myFunc(x)), 프로시저는 작업을 시키는 것(CALL myProc())입니다. 그래서 함수는 컬럼 자리에 끼워 넣을 수 있고, 프로시저는 여러 행을 결과로 낼 수 있습니다.
RETURNS와 RETURN을 구분해 설명해보세요.
RETURNS INT는 함수 머리 부분의 선언으로 "이 함수는 INT를 돌려준다"는 약속이고, RETURN VALUE1+VALUE2;는 본문 안에서 실제로 값을 돌려주는 명령입니다. s가 붙으면 선언, 없으면 실행이라고 기억하면 됩니다.
💼 실무·코딩테스트에서는실무에서 함수는 회사마다 다른 계산 규칙(등급 산정, 수수료, 세금)을 한 곳에 모아 둘 때 유용합니다. 다만 WHERE 절에 함수를 쓰면 인덱스를 못 쓰므로(42·43번 카드) 성능에 주의해야 합니다. 계산은 함수로, 검색 조건은 맨몸 컬럼으로가 요령입니다.
🖥️ 7. 웹 개발 — JDBC · JSP · Servlet · Spring MVC · MyBatis
SQL 과정을 마치고 웹 애플리케이션 개발로 넘어와 정리한 노트입니다(카드 01~61). Eclipse JEE + Tomcat 10.1 환경과 다이나믹 웹 프로젝트 구조에서 시작해 DTO · DAO와 JDBC 6단계(01~), JSP 스크립틀릿·요청 값 받기·화면 이동·한글 인코딩, MVC1 구조와 그 한계, Servlet과 프런트 컨트롤러(MVC2), EL · JSTL, Spring MVC와 MyBatis, 그리고 답변형 게시판 · 페이징 · 트랜잭션 · AOP까지 순서대로 다룹니다. 수업 프로젝트 01_userboard_sepa ~ 08_answerboard_springMVC의 흐름을 따라가며, 카드의 코드는 개념 설명용 예시이고 실행 검증 로그와 구별합니다. 원본 실습 프로젝트는 web_edu_project에 있습니다 — 공개 저장소라 DB 비밀번호는 빼고 올렸으니, 내려받아 쓸 때는 README의 안내대로 채워 넣으면 됩니다.
01
개발 환경 — Eclipse JEE · Tomcat · 버전 궁합
Eclipse JEETomcat 10.1WASServlet 6.0
한 줄 요약웹 애플리케이션은 스스로 실행되지 않는다. 요청을 받아 자바 코드를 대신 실행해 주는 WAS(Tomcat)가 필요하고, Tomcat 버전에 따라 쓸 수 있는 Servlet·JSP 스펙과 JDK가 정해진다.
쉽게 말하면지금까지 만든 자바 프로그램은 main()이 있어서 내가 실행 버튼을 누르면 돌았습니다. 웹은 다릅니다 — 누군가 브라우저로 요청할 때 코드가 돌아야 하죠. 그 "누군가를 기다리다가 대신 실행해 주는 프로그램"이 WAS이고, 우리가 쓰는 것이 Tomcat입니다. 그래서 내 코드는 Tomcat이라는 집에 세들어 사는 구조가 됩니다.
네임스페이스가 javax가 아니라 jakarta인 점이 중요합니다. 오라클이 Java EE를 이관하면서 Tomcat 10부터 패키지 이름이 javax.servlet → jakarta.servlet으로 통째로 바뀌었습니다. 그래서 인터넷에서 찾은 예제가 import javax.servlet...이면 Tomcat 10에서는 동작하지 않습니다 — 오래된 자료를 볼 때 가장 먼저 확인할 부분입니다.
01번 정리 — 핵심 정리
웹 애플리케이션은 WAS(Tomcat) 위에서 돌아간다 — 내가 실행하는 게 아니라 요청이 오면 WAS가 실행한다.
Tomcat 버전이 Servlet·JSP 스펙과 JDK 범위를 정한다 — 아무 버전이나 섞을 수 없다.
Tomcat 10부터 javax.* → jakarta.*로 패키지가 바뀌었다. 옛 예제를 그대로 쓰면 컴파일이 안 된다.
Eclipse는 JEE 버전을 받아야 한다 — 일반 Eclipse에는 웹 프로젝트·서버 연동 기능이 없다.
Tomcat은 Eclipse 안에 서버로 등록해서 쓴다(Servers 프로젝트가 그 설정을 담는다).
기본 포트는 8080이며, 충돌하면 server.xml에서 바꾼다(교재는 8090을 쓰기도 한다).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
웹 애플리케이션에는 왜 main()이 없을까?
실행을 시작하는 주체가 내가 아니라 WAS이기 때문입니다. 브라우저 요청이 들어오면 Tomcat이 알맞은 클래스를 찾아 대신 호출합니다. 그래서 내 코드는 "요청이 오면 이렇게 하라"는 부품으로 작성되고, 전체 흐름은 WAS가 쥡니다. 이런 구조를 제어의 역전이라 부릅니다.
인터넷 예제에 import javax.servlet.* 이 있는데 컴파일이 안 된다면?
Tomcat 10 이상을 쓰고 있기 때문입니다. Java EE가 Jakarta EE로 이관되면서 패키지 이름이 jakarta.servlet.*로 바뀌었습니다. javax를 jakarta로 바꾸면 대부분 해결되고, 안 되면 그 예제가 Tomcat 9 이하 기준인 것입니다.
💼 실무·코딩테스트에서는실무에서 버전 궁합은 프로젝트 시작 전에 확정합니다. "JDK 21 + Tomcat 10.1 + Spring 6"처럼 조합을 문서로 못 박아 두지 않으면 팀원마다 환경이 달라 "제 컴퓨터에선 되는데요"가 반복됩니다. javax → jakarta 전환은 레거시 프로젝트를 올릴 때 가장 큰 걸림돌이라 면접에서도 종종 나옵니다.
02
다이나믹 웹 프로젝트 구조 — WEB-INF는 왜 숨겨져 있나
Dynamic Web ProjectWEB-INFweb.xmllib
한 줄 요약웹 프로젝트는 정해진 폴더 구조 약속을 따른다. 그중 WEB-INF 안의 파일은 브라우저가 직접 요청할 수 없어, 설정 파일과 라이브러리·클래스를 숨기는 자리로 쓴다.
쉽게 말하면webapp 폴더는 웹에 공개되는 공간입니다. 여기 index.jsp를 두면 브라우저에서 바로 열리죠. 그런데 설정 파일이나 DB 비밀번호가 든 클래스가 그대로 열리면 큰일입니다. 그래서 WEB-INF라는 특별한 폴더가 있고, 이 안은 서버만 읽을 수 있고 브라우저는 절대 못 봅니다. 규칙이 아니라 서블릿 스펙에 정해진 보안 장치입니다.
JAVA01_userboard_sepa 프로젝트 구조
01_userboard_sepa/
├─ src/main/java/ ← 자바 소스 (컴파일되면 classes로 간다)
│ └─ com/hk/board/
│ ├─ dto/userDto.java 데이터를 담아 나르는 객체
│ ├─ dao/UserDao.java DB 접근 전담 객체
│ └─ main/UserMain.java 테스트용 실행 클래스
└─ src/main/webapp/ ← 웹에 공개되는 루트
├─ index.jsp 브라우저에서 바로 열림
└─ WEB-INF/ ← 브라우저가 직접 못 여는 영역
├─ web.xml 배포 서술자(설정)
└─ lib/
└─ mariadb-java-client-3.3.3.jar JDBC 드라이버
welcome-file-list는 주소에 파일명 없이 들어왔을 때 무엇을 보여줄지 정합니다. localhost:8080/01_userboard_sepa/로 요청하면 목록을 위에서부터 찾아 가장 먼저 존재하는 파일을 응답하죠. 우리 프로젝트에는 index.html이 없고 index.jsp가 있으니 그것이 열립니다.
JDBC 드라이버 jar를 WEB-INF/lib에 넣는 것도 약속입니다. 이 폴더의 jar는 서버가 자동으로 클래스패스에 넣어 주므로 따로 설정할 필요가 없습니다.
02번 정리 — 핵심 정리
webapp은 웹에 공개되는 루트다 — 여기 둔 파일은 브라우저에서 바로 열린다.
WEB-INF 안은 브라우저가 직접 요청할 수 없다 — 서블릿 스펙이 보장하는 보안 장치다.
WEB-INF/lib의 jar는 서버가 자동으로 클래스패스에 포함한다.
WEB-INF/classes에 컴파일된 .class가 들어간다(소스는 src에 두고 빌드가 옮긴다).
web.xml(배포 서술자)은 서블릿 매핑·시작 페이지 등 앱 설정을 담는다. 요즘은 어노테이션으로 대체하는 부분이 많다.
welcome-file-list는 파일명 없이 들어온 요청에 무엇을 보여줄지 정한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DB 비밀번호가 든 설정 파일을 어디에 두어야 안전한가? 왜?
WEB-INF 안입니다. 이 폴더는 브라우저가 URL로 직접 요청할 수 없도록 서블릿 스펙이 막고 있기 때문입니다. webapp 바로 아래에 두면 /config.properties 같은 주소로 누구나 내려받을 수 있습니다.
JSP 파일을 WEB-INF 안에 두면 어떻게 되나?
브라우저가 직접 열 수 없게 됩니다. 이건 단점이 아니라 일부러 쓰는 기법이에요 — 화면 JSP를 WEB-INF/views/에 두면 반드시 컨트롤러를 거쳐야만 볼 수 있어, 로그인 검사를 건너뛰고 화면에 바로 접근하는 것을 막습니다. MVC 패턴에서 표준적으로 쓰는 방식입니다.
💼 실무·코딩테스트에서는실무 스프링 프로젝트에서도 JSP는 WEB-INF/views에 두는 것이 관례입니다 — 직접 접근을 막아 인증·권한 검사를 우회할 수 없게 하기 위해서죠. "왜 뷰 파일을 WEB-INF에 두나요?"는 면접에서 나올 수 있는 질문이고, 보안이 정답입니다.
03
DTO — 데이터를 담아 나르는 객체
DTO캡슐화getter/setterSerializable
한 줄 요약DB에서 꺼낸 한 행이나 화면에서 받은 한 건을 객체 하나에 담아 계층 사이로 옮기는 것이 DTO다. 멤버필드 + 생성자 + getter/setter + toString으로 이루어진 단순한 상자다.
쉽게 말하면회원 정보를 넘기려면 아이디·이름·나이·주소·전화·키·가입일까지 일곱 개를 따로 들고 다녀야 합니다. 메서드 매개변수가 일곱 개가 되죠. DTO는 그걸 봉투 하나에 담아insertUser(dto)처럼 한 개로 만들어 줍니다. 필드가 늘어도 메서드 시그니처는 그대로라는 게 진짜 이득입니다.
JAVAuserDto.java — 오늘 만든 DTO
package com.hk.board.dto;
import java.io.Serializable;
import java.util.Date;
public class userDto implements Serializable {
// 1) 멤버필드 — private 으로 감춘다(은닉화)
private String userId;
private String name;
private int birthYear;
private String addr;
private String mobile1;
private String mobile2;
private int height;
private Date mDate; // java.util.Date
// 2) 기본 생성자 — 빈 객체를 만들고 setter 로 채울 때 필요
public userDto() { }
// 3) 생성자 오버로딩 — 한 번에 채워 만들 때
public userDto(String userId, String name, int birthYear, String addr,
String mobile1, String mobile2, int height, Date mDate) {
super();
this.userId = userId;
this.name = name;
// ... 나머지 필드도 동일
}
// 4) getter / setter — 감춘 필드에 드나드는 통로
public String getUserId() { return userId; }
public void setUserId(String v) { this.userId = v; }
// ... 필드마다 한 쌍씩
// 5) toString — 내용을 사람이 읽을 수 있게
@Override
public String toString() {
return "userDto [userId=" + userId + ", name=" + name + " ... ]";
}
}
실행 결과UserMain으로 조회해 toString()이 찍힌 결과 먼저 예측 → 펼쳐서 확인
System.out.println(dto)만 했는데 필드가 다 보이는 것은 toString()을 재정의했기 때문입니다. 재정의하지 않으면 com.hk.board.dto.userDto@1b6d3586처럼 클래스명@해시값이 찍혀 디버깅이 어렵습니다. → 🧱 02. Object 클래스와 4대 메서드
참고로 이 toString()에는 mDate가 빠져 있어 가입일이 출력되지 않습니다. 필드를 추가할 때 toString() 갱신을 잊기 쉬운데, IDE 자동 생성이나 Lombok을 쓰면 이런 누락이 줄어듭니다.
03번 정리 — 핵심 정리
DTO는 데이터를 담아 계층 사이로 옮기는 객체다. 로직은 넣지 않는 것이 원칙이다.
구성: 멤버필드 · 생성자 · getter/setter · toString.
필드를 private으로 감추고 getter/setter로만 드나들게 하는 것이 캡슐화다. → 🧱 04. 접근 제한자
기본 생성자는 꼭 남겨 둔다 — 프레임워크가 빈 객체를 만든 뒤 setter로 채우는 방식을 많이 쓴다.
implements Serializable은 객체를 바이트로 바꿔 저장·전송할 수 있게 하는 표시다.
Lombok을 쓰면 필드만 쓰고 @Getter·@Setter·@ToString 어노테이션으로 나머지를 대신할 수 있다.
클래스명은 파스칼 표기(UserDto)가 관례다 — 이 프로젝트의 userDto는 소문자로 시작해 관례에서 벗어나 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DTO에 매개변수를 하나로 묶는 것 말고 또 어떤 이득이 있나?
필드가 늘어도 메서드 시그니처가 안 바뀝니다. 매개변수를 일곱 개 늘어놓았다면 컬럼이 하나 추가될 때 그 메서드를 부르는 모든 곳을 고쳐야 하죠. DTO면 DTO 클래스 한 곳만 고치면 됩니다. 또 순서를 헷갈려 엉뚱한 값을 넣는 실수도 사라집니다.
기본 생성자를 남겨 두라는 이유는 무엇인가?
프레임워크가 리플렉션으로 빈 객체를 먼저 만들고 setter로 채우는 방식을 쓰기 때문입니다. JSP의 <jsp:useBean>, JSON 변환기, JPA가 전부 그렇죠. 생성자를 직접 하나 만들면 기본 생성자가 자동으로 사라지므로 따로 추가해 줘야 합니다. → 🧱 07. 생성자
💼 실무·코딩테스트에서는DTO는 실무에서 가장 많이 만드는 클래스입니다. 스프링에서는 요청용·응답용·엔티티를 각각 나누는 것이 관례인데, "화면에 보여줄 형태"와 "DB에 저장할 형태"가 다르기 때문입니다. Lombok으로 보일러플레이트를 줄이는 것도 사실상 표준입니다.
04
DAO — DB 접근을 전담하는 객체
DAOCRUD계층 분리List 반환
한 줄 요약DB에 연결해 CRUD를 실행하는 코드를 한 클래스에 모아 둔 것이 DAO다. 다른 코드는 dao.getAllUser()처럼 메서드만 부르고, SQL이 어떻게 생겼는지는 몰라도 된다.
쉽게 말하면DAO는 DB와 대화하는 창구 직원입니다. 손님(다른 코드)은 "회원 목록 주세요"라고만 하면 되고, 어떤 SQL을 쓰는지·어느 DB에 붙는지는 창구가 알아서 합니다. 이렇게 해 두면 DB를 MariaDB에서 다른 것으로 바꿔도 창구 안쪽만 고치면 되고, 손님 쪽 코드는 손댈 필요가 없습니다.
JAVAUserDao.java — 조회 메서드의 뼈대
package com.hk.board.dao;
public class UserDao {
// 생성자에서 드라이버를 한 번만 로딩한다 (JDBC 1단계)
public UserDao() {
try {
Class.forName("org.mariadb.jdbc.Driver");
System.out.println("1단계: 드라이버 로딩 성공");
} catch (ClassNotFoundException e) {
System.out.println("1단계: 드라이버 로딩 실패");
e.printStackTrace();
}
}
// 회원 목록 조회 — 여러 행이므로 List<userDto> 를 돌려준다
public List<userDto> getAllUser() {
List<userDto> list = new ArrayList<>();
String sql = " SELECT USERID, NAME, BIRTHYEAR, ADDR, MOBILE1, MOBILE2,"
+ " HEIGHT, MDATE FROM USERTBL ORDER BY MDATE DESC ";
// ... 2~6단계 (다음 카드)
return list;
}
// 회원 등록 — 성공 여부만 알면 되므로 boolean
public boolean insertUser(userDto dto) {
// ...
return count > 0 ? true : false;
}
}
// 부르는 쪽은 SQL을 전혀 모른다
UserDao dao = new UserDao();
List<userDto> list = dao.getAllUser();
for (userDto d : list) System.out.println(d);
실행 결과반환 타입을 무엇으로 정하나 먼저 예측 → 펼쳐서 확인
조회 결과가 여러 행 → List<userDto> 회원 목록
조회 결과가 한 행 → userDto 회원 상세
성공/실패만 필요 → boolean 등록·수정·삭제
바뀐 행 수가 필요 → int 일괄 처리
실제 실행 결과
dao.getAllUser() → 4건 (mDate DESC 정렬)
dao.insertUser() → true
같은 아이디로 또 → false (PK 중복이라 실패)
중복 아이디로 등록을 시도했더니 false가 돌아왔습니다. 예외가 나서 프로그램이 죽은 게 아니라, DAO가 예외를 잡아 실패를 boolean으로 알려 준 것이죠. 부르는 쪽은 if (dao.insertUser(dto))로 성공·실패를 분기하면 됩니다.
다만 이렇게 하면 왜 실패했는지는 알 수 없습니다(중복인지, DB가 죽었는지). 실무에서는 예외를 그대로 위로 던지거나 의미를 담은 예외로 바꿔 던지는 방식을 더 많이 씁니다.
04번 정리 — 핵심 정리
DAO는 DB 접근 코드를 한곳에 모으는 객체다 — 부르는 쪽은 SQL을 모른다.
반환 타입은 결과의 모양에 맞춘다: 여러 행이면 List<Dto>, 한 행이면 Dto, 성공 여부면 boolean.
매개변수가 많아지면 DTO로 묶어 받는다 — insertUser(userDto dto).
드라이버 로딩은 생성자에서 한 번만 한다(메서드마다 할 필요 없다).
예외를 잡아서 false로만 알리면 원인을 알 수 없다 — 실무에서는 예외를 위로 던지는 편이 많다.
DB 접속 정보를 메서드마다 반복해 적고 있는데, 상수나 별도 클래스로 빼는 것이 다음 단계다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DAO를 따로 두면 DB를 바꿀 때 무엇이 편해지나?
고칠 범위가 DAO 안으로 한정됩니다. MariaDB를 다른 DB로 바꾸면 드라이버 이름과 URL, 일부 SQL 문법만 바뀌는데 전부 DAO 안에 있으니 그 클래스만 손보면 되죠. dao.getAllUser()를 부르는 코드는 한 줄도 바뀌지 않습니다.
insertUser가 예외를 잡아 false만 돌려주는 방식의 한계는?
실패 원인을 구분할 수 없습니다. 아이디 중복인지, DB가 안 떠 있는지, 필수값이 빠졌는지 모두 똑같이 false죠. 화면에 "이미 사용 중인 아이디입니다" 같은 안내를 하려면 원인을 알아야 하므로, 예외를 위로 던지거나 결과 객체에 사유를 담아 돌려주는 방식이 필요합니다.
💼 실무·코딩테스트에서는DAO 패턴은 스프링에서 Repository로 이어집니다 — 이름만 바뀌고 역할은 같죠. 실무에서는 MyBatis나 JPA가 이 반복 코드를 대신 써 주지만, 지금 손으로 만들어 보는 경험이 있어야 그 도구들이 무엇을 대신해 주는지 알 수 있습니다.
05
JDBC 6단계 — 자바가 DB와 대화하는 순서
JDBCConnectionPreparedStatementResultSet
한 줄 요약자바에서 DB를 쓰는 절차는 ① 드라이버 로딩 ② 연결 ③ 쿼리 준비 ④ 실행 ⑤ 결과 받기 ⑥ 닫기 여섯 단계로 정해져 있다. 어떤 DB를 쓰든 이 뼈대는 같다.
쉽게 말하면JDBC는 자바와 DB 사이의 공통 규격입니다. MariaDB든 Oracle이든 자바 쪽 코드는 똑같이 쓰고, 각 DB 회사가 만든 드라이버(jar)가 그 차이를 흡수해 주죠. 그래서 1단계에서 어떤 드라이버를 로딩하느냐와 2단계의 접속 주소만 바꾸면 다른 DB로 갈아탈 수 있습니다.
JAVAUserDao.getAllUser() — 6단계 전체
// 1단계: 드라이버 로딩 (생성자에서 한 번)
Class.forName("org.mariadb.jdbc.Driver");
String url = "jdbc:mariadb://localhost:3306/hk";
String user = "root";
String password = System.getenv("STUDY_DB_PASSWORD"); // 소스에 그대로 적으면 안 된다(아래 설명)
Connection conn = null; // DB 연결
PreparedStatement psmt = null; // 쿼리 준비
ResultSet rs = null; // 결과 받기
try {
// 2단계: DB 연결
conn = DriverManager.getConnection(url, user, password);
// 3단계: 쿼리 준비
psmt = conn.prepareStatement(sql);
// 4단계: 쿼리 실행
rs = psmt.executeQuery();
// 5단계: 결과 받기 — 행 단위로 훑으며 열을 꺼낸다
while (rs.next()) {
userDto dto = new userDto();
dto.setUserId(rs.getString(1));
dto.setName(rs.getString(2));
dto.setBirthYear(rs.getInt(3));
// ...
list.add(dto); // 다 채운 뒤에 담는다
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
// 6단계: 닫기 — 연 순서의 역순으로
// (간추린 발췌: close()도 SQLException을 던지므로, 실제 UserDao처럼
// 이 세 줄을 try { ... } catch (SQLException e) { ... }로 한 번 더 감싸야 컴파일된다 → 07번 카드)
if (rs != null) rs.close();
if (psmt != null) psmt.close();
if (conn != null) conn.close();
}
실행 결과실제로 돌려 본 콘솔 출력 먼저 예측 → 펼쳐서 확인
1단계: 드라이버 로딩 성공
2단계: DB연결 성공
3단계: 쿼리준비 성공
4단계: 쿼리실행 성공
5단계: 쿼리결과 받기 성공
6단계: DB닫기 성공
userDto [userId=JYP, name=조용필, birthYear=1950, ...]
userDto [userId=KBS, name=김범수, birthYear=1979, ...]
userDto [userId=LSG, name=이승기, birthYear=1987, ...]
userDto [userId=KKH, name=김경호, birthYear=1971, ...]
단계마다 출력을 찍어 둔 것이 학습에 아주 유효합니다. 어디까지 성공했는지 보이니, 실패하면 몇 단계에서 막혔는지 바로 알 수 있죠 — 2단계에서 멈추면 접속 정보나 DB 기동 문제, 4단계에서 멈추면 SQL 문제입니다.
rs.next()는 "다음 행으로 옮기고 값이 있으면 true"입니다. 처음에 커서가 첫 행 앞에 있어서, next()를 한 번 불러야 첫 행을 읽을 수 있습니다.
컬럼 번호는 1부터입니다(rs.getString(1)). 자바 배열이 0부터인 것과 달라 헷갈리기 쉬운데, 번호 대신 rs.getString("NAME")처럼 컬럼명을 쓰면 SQL의 컬럼 순서가 바뀌어도 안전합니다.
05번 정리 — 핵심 정리
순서: ① 드라이버 로딩 ② 연결 ③ 쿼리 준비 ④ 실행 ⑤ 결과 받기 ⑥ 닫기.
Class.forName("org.mariadb.jdbc.Driver") — 드라이버 클래스를 이름으로 찾아 메모리에 올린다.
접속 주소 형식: jdbc:mariadb://호스트:포트/디비명.
조회는 executeQuery()(→ ResultSet), 변경은 executeUpdate()(→ 바뀐 행 수 int).
rs.next()를 한 번 불러야 첫 행을 읽는다 — 커서가 처음엔 첫 행 앞에 있다.
컬럼 번호는 1부터. 번호보다 컬럼명으로 꺼내는 편이 안전하다.
닫기는 연 순서의 역순(rs → psmt → conn)으로 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
rs.next()를 부르지 않고 바로 rs.getString(1)을 하면 어떻게 되나?
예외가 납니다.ResultSet의 커서는 처음에 첫 행 앞(before first)에 놓여 있어서, 아직 어느 행도 가리키지 않기 때문입니다. next()가 커서를 한 칸 옮기고 그 자리에 행이 있으면 true를 돌려주므로, 반드시 먼저 불러야 합니다.
6단계 닫기를 빠뜨리면 어떤 일이 생기나?
커넥션이 반납되지 않고 쌓입니다. DB가 동시에 받을 수 있는 연결 수는 정해져 있어서, 계속 새지면 어느 순간 "더 이상 연결할 수 없다"며 서비스 전체가 멈춥니다. 당장은 멀쩡해 보이다가 운영 중에 서서히 죽는 무서운 버그라, finally나 try-with-resources로 반드시 닫아야 합니다.
💼 실무·코딩테스트에서는실무에서 JDBC를 이렇게 직접 쓰는 일은 드뭅니다 — MyBatis·JPA가 이 여섯 단계를 대신해 주니까요. 하지만 커넥션 누수, 트랜잭션, 커넥션 풀 같은 문제를 이해하려면 그 아래에서 무슨 일이 일어나는지 알아야 합니다. 지금 손으로 써 보는 것이 그래서 의미가 있습니다.
06
PreparedStatement와 ? 바인딩 — SQL 인젝션을 막는 방법
PreparedStatement물음표 바인딩SQL 인젝션executeUpdate
한 줄 요약SQL에 값을 문자열로 이어 붙이지 않고?로 자리만 비워 둔 뒤 psmt.setString(1, 값)으로 채운다. 이렇게 하면 값이 SQL 문법으로 해석되지 않아 SQL 인젝션이 막힌다.
쉽게 말하면"... WHERE id = '" + 입력값 + "'"처럼 이어 붙이면, 누군가 입력값에 ' OR '1'='1을 넣는 순간 조건이 항상 참인 SQL이 되어 남의 정보가 통째로 나옵니다. ?를 쓰면 DB가 "이 자리는 값이 들어올 자리"라고 먼저 못 박아 두기 때문에, 무엇을 넣든 문법이 아니라 그냥 문자열로 취급됩니다.
JAVAUserDao.insertUser() — ? 로 자리를 비우고 채운다
// 값을 이어 붙이지 않고 ? 로 자리만 비운다
String sql = " INSERT INTO USERTBL VALUES(?,?,?,?,?,?,?,SYSDATE()) ";
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
// ? 를 순서대로 채운다 — 번호는 1부터
psmt.setString(1, dto.getUserId());
psmt.setString(2, dto.getName());
psmt.setInt (3, dto.getBirthYear());
psmt.setString(4, dto.getAddr());
psmt.setString(5, dto.getMobile1());
psmt.setString(6, dto.getMobile2());
psmt.setInt (7, dto.getHeight());
// INSERT·UPDATE·DELETE 는 executeUpdate() → 바뀐 행 수
count = psmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
return count > 0 ? true : false;
// 절대 이렇게 쓰지 말 것 (SQL 인젝션에 뚫린다)
// String sql = "SELECT * FROM USERTBL WHERE USERID = '" + id + "'";
실행 결과직접 실행한 결과 먼저 예측 → 펼쳐서 확인
[정상 등록]
쿼리 준비 완료
등록 결과: true
[같은 아이디로 한 번 더]
쿼리 준비 완료
[WARN] Error: 1062-23000: Duplicate entry 'JYP' for key 'PRIMARY'
java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'JYP'
등록 결과: false
* SYSDATE() 로 오늘 날짜가 들어가서, ORDER BY MDATE DESC 인
목록에서 방금 넣은 회원이 맨 위로 올라온다.
여기서 ?는 7개인데 컬럼은 8개입니다. 마지막 mDate는 SYSDATE()로 DB가 직접 오늘 날짜를 넣기 때문에 자바에서 채우지 않죠. 가입일처럼 서버 시각이 기준이어야 하는 값은 이렇게 DB에 맡기는 편이 안전합니다 — 클라이언트 시계는 틀릴 수 있으니까요.
?의 개수와 set 호출 수가 어긋나면 실행할 때 에러가 납니다 — 예를 들어 set을 하나 빠뜨리면 MariaDB 드라이버는 Parameter at position 7 is not set처럼 몇 번째 ?가 비었는지 알려 줍니다. 컬럼을 추가할 때 SQL·set·DTO 세 곳을 같이 고쳐야 하는 것이 이 방식의 번거로운 점이고, MyBatis가 대신해 주는 일이기도 합니다.
06번 정리 — 핵심 정리
값은 이어 붙이지 말고 ?로 자리를 비운 뒤 set으로 채운다 — SQL 인젝션 방어의 핵심.
? 번호는 1부터이고, 타입에 맞는 setString·setInt·setDate를 쓴다.
조회는 executeQuery(), 변경은 executeUpdate() — 후자는 바뀐 행 수를 돌려준다.
executeUpdate() 결과가 0이면 아무것도 안 바뀐 것 — 성공 여부 판단에 쓴다.
DB·드라이버 설정에 따라 같은 쿼리를 반복 실행할 때 빨라질 수도 있다(서버 쪽 prepare로 실행 계획 재사용). 다만 MariaDB Connector/J는 기본값이 클라이언트 쪽 처리라 속도 이득이 보장되지는 않는다 — 쓰는 진짜 이유는 인젝션 방어다.
SYSDATE()처럼 DB가 채우는 값은 ?로 두지 않는다.
? 개수와 set 호출 수가 맞아야 한다 — 컬럼 추가 시 SQL·set·DTO를 함께 고칠 것.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
' OR '1'='1 이 왜 위험한지, PreparedStatement가 어떻게 막는지 설명해보세요.
문자열로 이어 붙이면 WHERE id = '' OR '1'='1'이 되어 조건이 항상 참이 되고 전체 행이 나옵니다. ?를 쓰면 DB가 쿼리의 구조를 먼저 확정한 뒤 값을 나중에 끼우기 때문에, 값 안에 무엇이 들어 있든 문법이 아니라 문자열 하나로만 취급됩니다.
executeQuery와 executeUpdate를 언제 각각 쓰나?
executeQuery()는 SELECT에 쓰고 ResultSet을 돌려받습니다. executeUpdate()는 INSERT·UPDATE·DELETE에 쓰고 바뀐 행 수(int)를 돌려받죠. 반환값이 다르니 int count = psmt.executeQuery();처럼 반환값을 받는 자리에서 바꿔 쓰면 컴파일 오류가 납니다. 반환값을 받지 않고 INSERT에 executeQuery()만 부르면 컴파일은 되지만 실행 중 예외가 날 수 있으니, 처음부터 용도에 맞게 고르세요.
💼 실무·코딩테스트에서는SQL 인젝션은 웹 보안 취약점의 대표이고, 실무 코드 리뷰에서 문자열로 SQL을 조립하는 코드는 즉시 지적됩니다. MyBatis에서도 #{}(바인딩)와 ${}(문자열 치환)가 정확히 이 차이라, ${}는 꼭 필요할 때만 쓰라고 배웁니다.
07
자원 반납 — finally와 try-with-resources
finallytry-with-resources자원 누수close()
한 줄 요약DB 연결은 다 쓰면 반드시 닫아야 한다. finally에서 직접 닫는 방법과, 괄호 안에 선언하면 자동으로 닫아 주는 try-with-resources 두 가지가 있다.
쉽게 말하면DB 연결은 빌려 쓰는 자원입니다. 도서관 책처럼 반납해야 다음 사람이 쓸 수 있죠. 안 닫으면 당장은 멀쩡한데 시간이 지나며 서서히 고갈되어 어느 순간 서비스 전체가 멈춥니다. 그래서 예외가 나든 안 나든 반드시 실행되는 자리인 finally에 닫기를 두거나, 아예 자바가 자동으로 닫아 주게 맡깁니다.
JAVA한 프로젝트 안에 두 방식이 다 들어 있다
// ① getAllUser() — finally 에서 직접 닫는 방식
Connection conn = null; PreparedStatement psmt = null; ResultSet rs = null;
try {
conn = DriverManager.getConnection(url, user, password);
psmt = conn.prepareStatement(sql);
rs = psmt.executeQuery();
// ...
} catch (SQLException e) {
e.printStackTrace();
} finally {
try {
if (rs != null) rs.close(); // 연 순서의 역순으로
if (psmt != null) psmt.close();
if (conn != null) conn.close();
System.out.println("6단계: DB닫기 성공");
} catch (SQLException e) {
e.printStackTrace();
}
}
// ② insertUser() — try-with-resources (괄호 안에 선언하면 자동으로 닫힌다)
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
// ...
} catch (SQLException e) {
e.printStackTrace();
}
// close() 를 쓴 곳이 없다 — 그래서 "6단계" 출력도 없다
실행 결과두 방식의 출력 차이를 직접 확인 먼저 예측 → 펼쳐서 확인
[getAllUser() 호출]
1단계: 드라이버 로딩 성공
2단계: DB연결 성공
3단계: 쿼리준비 성공
4단계: 쿼리실행 성공
5단계: 쿼리결과 받기 성공
6단계: DB닫기 성공 ← finally 에서 직접 찍은 것
[insertUser() 호출]
1단계: 드라이버 로딩 성공
쿼리 준비 완료
등록 결과: true
← 단계 로그가 없다. close() 를 직접 안 쓰니까
같은 프로젝트인데 메서드마다 방식이 다른 것이 오히려 좋은 비교가 됩니다. insertUser에 "6단계" 출력이 없는 이유는 닫는 코드를 직접 쓰지 않았기 때문이지, 안 닫히는 것이 아닙니다 — 자바가 try 블록을 벗어날 때 선언한 역순으로 자동 호출합니다.
finally 방식의 번거로운 점은 close() 자체가 예외를 던질 수 있어 그 안에서 또 try-catch를 써야 한다는 것입니다. 실제로 코드가 중첩 try가 되어 있죠. try-with-resources는 그 문제까지 함께 해결합니다.
07번 정리 — 핵심 정리
DB 연결·파일·소켓은 다 쓰면 반드시 닫는다 — 안 닫으면 서서히 고갈된다.
finally는 예외가 나든 안 나든 실행되므로 닫기를 두기에 안전한 자리다.
닫는 순서는 연 순서의 역순(rs → psmt → conn).
close()도 예외를 던질 수 있어 finally 안에서 또 try-catch가 필요해진다 — 코드가 지저분해지는 이유.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
insertUser에 '6단계: DB닫기 성공' 출력이 없는데, 연결이 안 닫힌 걸까?
아닙니다. 닫힙니다. try-with-resources는 블록을 벗어날 때 자바가 자동으로 close()를 호출하므로, 내가 그 자리에 출력문을 넣을 곳이 없을 뿐입니다. 오히려 빠뜨릴 위험이 없어 더 안전합니다.
finally에서 close()를 부르다 또 예외가 나면 무엇이 문제인가?
원래 예외가 덮여 사라질 수 있습니다. try 블록에서 난 진짜 원인이 close()의 예외에 가려지면 디버깅이 어려워지죠. try-with-resources는 이런 경우 원래 예외를 살리고 close()의 예외는 suppressed로 붙여 두므로 둘 다 볼 수 있습니다.
💼 실무·코딩테스트에서는자원 누수는 운영 중에 서서히 서비스를 죽이는 대표적 버그입니다 — 개발·테스트에서는 요청이 적어 안 드러나다가, 트래픽이 몰리는 순간 커넥션 풀이 고갈되죠. 그래서 실무에서는 자원을 다루면 무조건 try-with-resources가 규칙이고, 정적 분석 도구도 이를 검사합니다.
08
계층 분리 — 왜 DTO와 DAO를 따로 만드나
계층 분리관심사의 분리MVC로 가는 길패키지 구조
한 줄 요약프로젝트 이름의 sepa는 separation(분리)다. 데이터를 담는 일(DTO)과 DB에 접근하는 일(DAO)과 화면·흐름을 다루는 일을 각각 다른 클래스로 나누면, 바뀔 때 고칠 범위가 좁아진다.
쉽게 말하면한 파일에 화면 HTML, SQL, 계산 로직을 다 넣으면 처음엔 빨라 보입니다. 그런데 화면 색만 바꾸려는데 SQL이 같이 보이고, SQL을 고치다 화면이 깨집니다. 계층을 나누면 "이건 화면 문제니까 JSP만 보면 된다"가 성립하죠. 이 프로젝트의 dto·dao·main 패키지가 그 첫 단추이고, 앞으로 배울 MVC가 이 분리를 화면·흐름·데이터로 더 밀고 나간 것입니다.
JAVA오늘 만든 패키지 구조와 역할
com.hk.board
├─ dto/ userDto.java 데이터를 담아 나른다 (무엇을)
├─ dao/ UserDao.java DB에 접근해 CRUD 한다 (어디서)
└─ main/ UserMain.java 시켜 보고 결과를 확인한다 (누가)
// main 은 DAO 메서드만 부른다 — SQL도 커넥션도 모른다
public class UserMain {
public static void main(String[] args) {
getAllListTest();
}
static public void getAllListTest() {
UserDao dao = new UserDao();
List<userDto> list = dao.getAllUser();
for (userDto userDto : list) {
System.out.println(userDto.toString());
}
}
}
// 앞으로 붙을 계층 (교육자료 개발 순서)
// 1.요구사항 분석 → 2.DB 설계 → 3.화면 설계
// 4.구현: DB → 프로젝트 템플릿 → DTO → DAO → 웹페이지·컨트롤러
// 5.테스트: 단위 테스트 → 통합 테스트
실행 결과분리해 두면 무엇이 달라지나 먼저 예측 → 펼쳐서 확인
바뀌는 것 고쳐야 할 곳
-------------------------------------------------
컬럼이 하나 늘었다 → DTO + DAO의 SQL
DB를 다른 것으로 바꾼다 → DAO 안 (드라이버·URL·일부 SQL)
화면 디자인만 바꾼다 → JSP 만
목록 정렬 기준을 바꾼다 → DAO의 SQL 한 줄
* UserMain 은 이 중 어떤 경우에도 바뀌지 않는다.
dao.getAllUser() 라는 약속만 지켜지면 되기 때문.
UserMain이 한 줄도 안 바뀐다는 것이 분리의 핵심 이득입니다. 부르는 쪽은 "회원 목록을 달라"는 약속(메서드 이름과 반환 타입)만 알고, 그 안이 어떻게 구현됐는지는 모릅니다. 그래서 안쪽을 아무리 고쳐도 바깥은 멀쩡하죠.
이것이 인터페이스에 의존하라는 원칙의 첫 경험이기도 합니다. 지금은 UserDao라는 클래스에 직접 의존하고 있지만, 나중에 인터페이스를 두고 구현을 갈아 끼우는 방식으로 발전합니다(스프링의 의존성 주입). → 🧱 22. 인터페이스
08번 정리 — 핵심 정리
계층 분리 = 관심사의 분리 — 한 클래스는 한 가지 일만 한다.
DTO는 데이터를 담고, DAO는 DB에 접근하고, 컨트롤러는 흐름을 정하고, JSP는 화면을 그린다.
교육자료의 개발 순서: 요구사항 → DB 설계 → 화면 설계 → 구현(DB·템플릿·DTO·DAO·화면) → 테스트.
이 분리를 화면·흐름·데이터로 더 밀고 나간 것이 MVC 패턴이다(다음 단계).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
클래스를 나누는 기준을 '바뀔 이유'로 설명해보세요.
함께 바뀌는 것은 모으고, 다른 이유로 바뀌는 것은 나눕니다. 화면 디자인이 바뀌는 이유와 DB 스키마가 바뀌는 이유는 완전히 다르죠. 한 파일에 있으면 한쪽을 고치다 다른 쪽을 깨뜨리게 됩니다. 이것이 단일 책임 원칙이 말하는 바입니다.
DAO 안의 SQL을 고쳤는데 UserMain을 안 고쳐도 되는 이유는?
둘 사이의 약속이 List<userDto> getAllUser()라는 시그니처이기 때문입니다. 그 약속만 지키면 안을 어떻게 구현하든 바깥은 영향받지 않습니다. 반대로 메서드 이름이나 반환 타입을 바꾸면 부르는 쪽을 전부 고쳐야 하므로, 이 경계를 잘 잡는 것이 설계입니다.
💼 실무·코딩테스트에서는계층 분리는 스프링의 출발점입니다 — Controller · Service · Repository 구조가 정확히 이 발상의 연장이죠. 실무에서 "이 코드는 어느 계층에 있어야 하나"를 판단하는 감각이 코드 리뷰의 큰 부분을 차지하고, 비즈니스 로직이 컨트롤러나 DAO에 새어 든 코드는 대표적인 지적 대상입니다.
한 줄 요약JSP는 HTML 안에 자바를 섞어 쓰는 파일이다. 이 카드에서 먼저 다루는 문법은 세 가지 — <%@ %> 지시자(설정), <% %> 스크립틀릿(실행만), <%= %> 표현식(화면에 출력)이다.
쉽게 말하면가장 헷갈리는 건 <%와 <%=의 차이입니다. 등호 하나 차이인데 하는 일이 완전히 달라요. <% %>는 주방에서 조용히 요리하는 곳이라 손님에게 아무것도 안 보이고, <%= %>는 완성된 접시를 손님 앞에 내놓는 것이라 화면에 글자가 찍힙니다. 실제로 수업 파일 주석에도 "<%는 조용히 실행만, <%=는 결과를 화면에 직접 출력"이라고 적어 두셨죠 — 정확한 정리입니다.
JSPuserList.jsp — 세 가지가 한 파일에 다 나온다
<%-- ① 지시자 <%@ %> : 이 파일의 설정. 자바 코드가 아니다 --%>
<%@page import="com.hk.board.dao.UserDao"%>
<%@page import="com.hk.board.dto.userDto"%>
<%@page import="java.util.List"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%-- ② 스크립틀릿 <% %> : 자바를 실행만 한다. 화면에는 아무것도 안 찍힌다 --%>
<%
UserDao dao = new UserDao();
List<userDto> list = dao.getAllUser();
%>
<body>
<table border="1">
<tr><th>아이디</th><th>이름</th><th>가입일</th><th>삭제</th></tr>
<%
for (userDto dto : list) { // ← 여는 중괄호까지만 쓰고 끊는다
%>
<tr>
<%-- ③ 표현식 <%= %> : 값을 화면에 찍는다. 끝에 세미콜론을 쓰지 않는다 --%>
<td><%=dto.getUserId()%></td>
<td><a href="userDetail.jsp?userid=<%=dto.getUserId()%>"><%=dto.getName()%></a></td>
<td><%=dto.getmDate()%></td>
</tr>
<%
} // ← 닫는 중괄호는 다음 스크립틀릿에서
%>
</table>
실행 결과실제로 브라우저가 받은 HTML (톰캣 10.1에서 실행) 먼저 예측 → 펼쳐서 확인
<body>
<h1>고객 조회 결과</h1>
<table border = "1">
<tr>
<th>아이디</th><th>이름</th><th>가입일</th><th>삭제</th>
<tr>
<td>SSK</td>
<td><a href="userDetail.jsp?userid=SSK">성시경</a></td>
<td>2013-12-12</td>
<td><a href="#" onClick="delUser('SSK')">삭제</a></td>
</tr>
<tr>
<td>BBK</td>
<td><a href="userDetail.jsp?userid=BBK">바비킴</a></td>
<td>2013-05-05</td>
...
* 자바 코드는 한 줄도 남아 있지 않다. 반복문이 돌아간 '결과'만 HTML로 남았다.
브라우저는 자바를 전혀 모릅니다.<% %>도 <%= %>도 서버에서 다 처리되고, 브라우저에는 순수한 HTML만 도착하죠. 실제로 결과에는 for문이 사라지고 <tr>이 6번 찍힌 것만 남았습니다.
<%= 안에는 세미콜론을 쓰지 않습니다.<%=dto.getName()%>는 out.print(dto.getName());로 번역되는데, 여기에 세미콜론을 넣으면 out.print(dto.getName(););가 되어 컴파일 에러가 납니다.
그리고 반복문이 여러 스크립틀릿으로 쪼개진다는 점이 처음엔 낯섭니다. {를 열고 스크립틀릿을 닫은 뒤 HTML을 쓰고, 다음 스크립틀릿에서 }를 닫죠. 중괄호 짝이 파일 전체에 흩어져 있어서, 하나만 빠져도 원인을 찾기 어렵습니다.
자바 주석 안에 %>를 쓰면 거기서 스크립틀릿이 끝나 버린다 — JSP는 주석인지 따지지 않고 %>만 찾는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
<% %>와 <%= %>의 차이를 한 문장으로 말해보세요.
<% %>는 실행만 하고 아무것도 찍지 않고, <%= %>는 값을 화면에 찍습니다.<%=x%>는 사실상 <% out.print(x); %>의 줄임말이에요. 그래서 <%= 안에는 '값이 되는 것'만 올 수 있고, for문이나 if문은 못 씁니다.
<%= dto.getName(); %> 처럼 세미콜론을 붙이면 왜 에러인가?
out.print( ... )의 괄호 안으로 통째로 들어가기 때문입니다. 세미콜론까지 들어가면 out.print(dto.getName(););가 되어 문법이 깨집니다. "표현식은 값이지 문장이 아니다"라고 기억하면 헷갈리지 않습니다.
스크립틀릿 안에 // 주석으로//아래 <%=dto.getName()%> 에서 터진다 라고 적으면 어떻게 될까?
주석 안의 %>에서 스크립틀릿이 끝나 버립니다. JSP는 파일을 서블릿으로 바꿀 때 자바 문법을 해석하지 않고 <%와 %>만 찾습니다. 그래서 // 주석 안이든 문자열 "%>" 안이든 가리지 않고 거기서 자바 영역을 닫아 버리죠.
뒷부분은 HTML로 취급되어 화면에 코드가 그대로 찍히거나 컴파일 에러가 납니다. 꼭 써야 하면 %\>로 escape 하거나, 그냥 %>를 안 쓰도록 문장을 바꾸는 편이 안전합니다.
💼 실무·코딩테스트에서는JSP에 자바를 직접 쓰는 스크립틀릿 방식은 실무에서 거의 쓰지 않습니다 — 화면과 로직이 뒤엉켜 유지보수가 어렵기 때문이죠. 요즘은 EL(${...})과 JSTL로 대체하고, 그마저도 Thymeleaf나 React 같은 프런트엔드로 넘어가는 추세입니다. 다만 레거시 프로젝트에는 여전히 스크립틀릿이 가득하고, 그걸 읽어야 하는 일이 실무 초기에 자주 생깁니다.
한 줄 요약브라우저가 보낸 값은 request.getParameter("이름")으로 꺼낸다. 숫자를 입력했어도 반드시 String으로 들어오므로, DTO의 int 필드에 넣으려면 Integer.parseInt로 변환해야 한다.
쉽게 말하면웹은 글자만 주고받습니다. 주소창이든 폼이든 전부 텍스트로 오죠. 키 칸에 178이라고 쳤어도 서버가 받는 건 숫자 178이 아니라 "178"이라는 글자입니다. 그래서 숫자로 쓸 거면 내가 직접 바꿔야 합니다. 그리고 바로 여기서 사용자가 엉뚱한 걸 입력하면 터지는 첫 번째 지점이 생깁니다.
JSPuserUpdate.jsp — 받아서, 바꾸고, 담는다
<%
// 값을 꺼내기 전에 인코딩부터 (→ 14번 카드)
request.setCharacterEncoding("UTF-8");
// ① 꺼낸다 — name 속성의 이름과 정확히 같아야 한다
String userId = request.getParameter("userid");
String addr = request.getParameter("addr");
String mobile1 = request.getParameter("mobile1");
String mobile2 = request.getParameter("mobile2");
String sHeight = request.getParameter("height"); // "178" 이라는 글자
// ② 바꾼다 — int 필드에 넣으려면 반드시 변환
int height = Integer.parseInt(sHeight);
// ③ 담아서 넘긴다 — userDto에는 수정할 5개 값만 받는 생성자도 오버로딩되어 있다
// (03번 카드 발췌에는 기본·8인자 생성자만 보이고 이 5인자 생성자는 생략됨)
UserDao dao = new UserDao();
boolean isS = dao.updateUser(
new userDto(userId, addr, mobile1, mobile2, height));
%>
<!-- 폼 예시(등록 폼 userInsertFrom.jsp): name 속성이 getParameter 의 열쇠다
등록 처리 userInsert.jsp 도 위와 똑같이 height 를 parseInt 한다 -->
<input type="text" name="height" required="required"/>
↑ 이 이름으로 꺼낸다
실행 결과등록 폼의 키 칸에 숫자가 아닌 값을 넣고 등록해 보면(같은 parseInt를 쓰는 userInsert.jsp) 먼저 예측 → 펼쳐서 확인
[정상 입력] height=172
→ HTTP 200, 회원 추가됨
[잘못된 입력] height=abc
→ HTTP 500 (내부 서버 오류)
근본 원인 (root cause)
java.lang.NumberFormatException: For input string: "abc"
* 폼에 required="required" 가 붙어 있어도 이걸 막지 못한다.
required 는 '비어 있는지'만 검사하지 '숫자인지'는 보지 않는다.
사용자 화면이 아니라 톰캣의 에러 화면이 통째로 나옵니다. "HTTP 상태 500" 아래에 자바 스택 트레이스가 그대로 노출되죠 — 실무라면 내부 구조가 공격자에게 보이는 보안 문제이기도 합니다.
required="required"는 '빈칸인지'만 봅니다. 글자가 하나라도 있으면 통과시키므로 abc는 그대로 서버로 옵니다. 게다가 HTML의 검사는 브라우저에서만 이뤄져서, 개발자 도구로 지우거나 주소창에 직접 요청하면 아예 없는 것과 같습니다.
그래서 입력 검사는 반드시 서버에서 한 번 더 해야 합니다. type="number"로 바꾸고, 서버에서도 try-catch로 감싸 친절한 안내 화면을 보여 주는 것이 다음 단계입니다.
10번 정리 — 핵심 정리
request.getParameter("이름")의 이름은 HTML의 name 속성과 정확히 같아야 한다.
반환 타입은 언제나 String — 숫자를 입력해도 마찬가지다.
int로 쓰려면 Integer.parseInt(문자열)로 변환한다.
이름이 틀리면 null이 돌아온다(에러가 아니라서 더 찾기 어렵다).
null에 Integer.parseInt를 하면 NumberFormatException이 난다.
required는 '빈칸인지'만 본다 — 숫자인지·형식이 맞는지는 검사하지 않는다.
HTML의 입력 검사는 브라우저에서만 동작한다 — 서버에서 반드시 한 번 더 확인해야 한다.
체크박스처럼 여러 값이 오는 경우는 getParameterValues()로 배열로 받는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
getParameter 의 이름을 오타냈다. 에러가 날까?
에러는 나지 않고 null이 돌아옵니다. 그래서 더 찾기 어렵죠 — 그 값을 그대로 DB에 넣으면 null이 저장되고, Integer.parseInt(null)을 하면 엉뚱한 자리에서NumberFormatException이 터집니다. 값이 안 나올 때는 HTML의 name 철자부터 확인하세요.
required가 붙어 있는데도 서버에서 다시 검사해야 하는 이유는?
required는 브라우저에서만 동작하기 때문입니다. 개발자 도구로 속성을 지우거나, 주소창에 직접 요청을 보내거나, curl 같은 도구로 보내면 검사를 통째로 건너뜁니다. 화면의 검사는 사용자 편의를 위한 것이고, 진짜 방어선은 서버에 있어야 합니다.
💼 실무·코딩테스트에서는"클라이언트의 입력은 절대 믿지 않는다"는 웹 개발의 제1원칙입니다. 브라우저에서 하는 검사는 사용자에게 빨리 알려 주기 위한 편의일 뿐, 보안이 아니에요. 스프링에서는 @Valid와 검증 애너테이션으로 이 서버 측 검사를 체계적으로 하고, 면접에서 "프런트 유효성 검사만으로 충분한가"를 물으면 정답은 언제나 아니오입니다.
11
페이지에서 페이지로 값 넘기기 — 쿼리스트링과 hidden
쿼리스트링hiddenGETPOST
한 줄 요약목록에서 상세로 갈 때는 주소 뒤에 ?userid=KBS를 붙여 넘기고(쿼리스트링), 화면에 보여 주기 싫지만 함께 보내야 하는 값은 <input type="hidden">으로 실어 보낸다.
쉽게 말하면JSP 파일 하나하나는 서로 남남입니다. userList.jsp에서 만든 변수를 userDetail.jsp가 알 방법이 없어요. 그래서 "누구를 보여 줄지"를 주소에 적어서 넘깁니다 — userDetail.jsp?userid=KBS처럼요. 받는 쪽은 request.getParameter("userid")로 꺼내면 됩니다. 주소창이 곧 쪽지인 셈입니다.
JSP목록 → 상세 → 수정, 값이 이어지는 세 단계
<%-- ① 목록: 이름에 링크를 걸면서 아이디를 주소에 실어 보낸다 --%>
<td><a href="userDetail.jsp?userid=<%=dto.getUserId()%>"><%=dto.getName()%></a></td>
<%-- ↑ 렌더링되면 userDetail.jsp?userid=KBS --%>
<%-- ② 상세: 받아서 한 명을 조회한다 --%>
<%
String userId = request.getParameter("userid");
UserDao dao = new UserDao();
userDto dto = dao.getUser(userId);
%>
<form action="userUpdate.jsp" method="post">
<%-- 아이디는 바꾸면 안 되니 화면에 입력칸을 주지 않는다.
하지만 "누구를 수정할지"는 넘겨야 하므로 hidden 으로 함께 보낸다 --%>
<input type="hidden" name="userid" value="<%=dto.getUserId()%>"/>
<td><input type="text" name="addr" value="<%=dto.getAddr()%>"/></td>
...
</form>
<%-- ③ 삭제: 자바스크립트로 확인을 받은 뒤 주소를 만들어 이동 --%>
<td><a href="#" onClick="delUser('<%=dto.getUserId()%>')">삭제</a></td>
<script>
function delUser(userId) {
if (confirm("정말 삭제하겠습니까?")) {
location.href = "userDel.jsp?userid=" + userId;
}
}
</script>
실행 결과실행해 보고 주소가 어떻게 만들어지는지 확인 먼저 예측 → 펼쳐서 확인
[목록 페이지가 만들어 낸 HTML]
<a href="userDetail.jsp?userid=SSK">성시경</a>
<a href="userDetail.jsp?userid=BBK">바비킴</a>
<a href="userDetail.jsp?userid=KBS">김범수</a>
[userDetail.jsp?userid=KBS 를 요청한 결과]
<input type="hidden" name="userid" value="KBS"/>
<th>아이디</th><td>KBS</td>
<th>이름</th> <td>김범수</td>
<th>지역</th> <td><input type="text" name="addr" value="경남"/></td>
<th>신장</th> <td><input type="text" name="height" value="173"/></td>
[수정 폼을 POST 로 보낸 뒤]
HTTP 200 → "회원정보를 수정했습니다" → userDetail.jsp?userid=KBS 로 되돌아감
DB 확인: addr=충북, mobile1=010, mobile2=99998888, height=175 로 바뀜
<a href="...?userid=<%=dto.getUserId()%>">가 낯설어 보이지만, 서버가 <%= %>를 값으로 바꾸고 나면 그냥 평범한 링크가 됩니다. 결과를 보면 userid=SSK, userid=BBK처럼 사람마다 다른 주소가 만들어져 있죠.
hidden 필드는 '안 보이지만 함께 가는' 값입니다. 아이디는 바꾸면 안 되는 값이라 입력칸을 주지 않았지만, 수정 처리 페이지는 누구를 수정할지 알아야 하므로 hidden으로 실어 보냅니다.
주의 — hidden은 화면에 안 보일 뿐 숨겨진 게 아닙니다. 브라우저에서 소스 보기를 하면 그대로 보이고, 값을 고쳐서 보낼 수도 있습니다. 그래서 권한 검사를 hidden 값에 의존하면 안 됩니다 — 남의 아이디로 바꿔 보내면 그대로 통하니까요.
11번 정리 — 핵심 정리
쿼리스트링: 주소 뒤에 ?이름=값&이름2=값2를 붙여 보낸다(GET).
받는 쪽은 request.getParameter("이름")으로 똑같이 꺼낸다 — GET·POST 구분 없이.
<input type="hidden">은 화면에 안 보이지만 폼과 함께 전송되는 값이다.
GET은 주소창에 값이 남고(즐겨찾기·공유 가능), POST는 본문에 실려 주소에 안 보인다.
조회는 GET, 변경은 POST가 원칙 — GET으로 삭제를 만들면 주소만 알면 누구나 지울 수 있다.
hidden은 숨김이 아니다 — 소스 보기로 다 보이고 값을 고쳐 보낼 수도 있다.
권한·금액처럼 중요한 값은 hidden으로 넘기지 않는다 — 서버에서 다시 조회해 확인한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
hidden 필드에 넣은 값은 안전한가?
전혀 안전하지 않습니다. 화면에 안 보일 뿐 HTML 소스에는 그대로 있고, 개발자 도구로 값을 고쳐서 보낼 수도 있습니다. 그래서 "이 사람이 이걸 수정할 권한이 있는가"는 hidden 값이 아니라 서버가 세션의 로그인 정보로 판단해야 합니다.
주소만 알면 누구나, 그리고 무심코 지울 수 있습니다. 링크는 GET이라 브라우저 미리보기나 검색엔진 크롤러가 눌러 버릴 수도 있고, 주소를 공유하면 받은 사람이 여는 순간 삭제됩니다. 그래서 데이터를 바꾸는 동작은 POST로 만들어야 합니다. 지금 코드의 confirm()은 실수는 막아 주지만 보안 장치는 아닙니다.
💼 실무·코딩테스트에서는URL 설계는 실무에서 생각보다 중요합니다. 조회는 GET, 변경은 POST라는 원칙을 REST에서는 GET·POST·PUT·DELETE로 더 세분화하죠. 그리고 "화면에서 넘어온 값으로 권한을 판단하지 않는다"는 것은 보안 취약점 목록(OWASP)에도 오르는 접근 통제 실패의 대표 사례입니다.
12
CRUD 완성 — 한 건 조회 · 수정 · 삭제
getUserupdateUserdeleteUsernull 반환
한 줄 요약목록 조회와 등록에 이어 한 건 조회(getUser)·수정(updateUser)·삭제(deleteUser)를 채워 DAO가 완성됐다. 한 건 조회는 while이 아니라 if를 쓰고, 못 찾으면 null을 돌려준다.
쉽게 말하면CRUD는 Create(등록) · Read(조회) · Update(수정) · Delete(삭제)의 앞 글자입니다. 거의 모든 업무 시스템이 결국 이 네 가지의 조합이라, 이걸 한 번 제대로 만들어 보면 다음 프로젝트는 테이블 이름만 바뀐 같은 일처럼 느껴집니다. 이 프로젝트에서 회원(user)으로 한 번, 구매(buy)로 또 한 번 만들면서 그걸 직접 확인하게 됩니다.
JAVAUserDao — 한 건 조회에서 달라지는 두 가지
// 여러 행 → List, 한 행 → Dto 하나. 못 찾을 수 있으니 null 로 시작한다
public userDto getUser(String userId) {
userDto dto = null;
String sql = " SELECT userid, NAME, birthyear, addr, mobile1, mobile2, height, mdate "
+ " FROM usertbl "
+ " WHERE userid = ? "; // ← 한 명만 집어내는 조건
try (Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement psmt = conn.prepareStatement(sql)) {
psmt.setString(1, userId);
// ResultSet 도 자원이므로 중첩 try-with-resources 로 자동 반납
try (ResultSet rs = psmt.executeQuery()) {
if (rs.next()) { // ← while 이 아니라 if
dto = new userDto(); // 찾았을 때만 상자를 만든다
dto.setUserId(rs.getString(1));
dto.setName(rs.getString(2));
// ...
}
}
} catch (Exception e) { e.printStackTrace(); }
return dto; // 찾았으면 데이터가 든 DTO, 못 찾았으면 null
}
// 수정 — ? 순서와 set 순서가 반드시 일치해야 한다
String sql = " UPDATE usertbl "
+ " SET addr = ?, mobile1 = ?, mobile2 = ?, height = ? "
+ " WHERE userid = ? "; // ← WHERE 의 ? 가 마지막(5번)
psmt.setString(1, dto.getAddr());
psmt.setString(2, dto.getMobile1());
psmt.setString(3, dto.getMobile2());
psmt.setInt (4, dto.getHeight());
psmt.setString(5, dto.getUserId()); // ← 기준이 되는 회원
// 삭제
String sql = " DELETE FROM usertbl WHERE userid = ? ";
실행 결과없는 아이디로 상세 페이지를 열어 보면 먼저 예측 → 펼쳐서 확인
[요청] userDetail.jsp?userid=NOPE
HTTP 500 – 내부 서버 오류
행 [29]에서 [/userDetail.jsp]을(를) 처리하는 중 예외 발생
근본 원인 (root cause)
java.lang.NullPointerException:
Cannot invoke "com.hk.board.dto.userDto.getUserId()" because "dto" is null
* DAO 는 잘못이 없다. 못 찾았으니 약속대로 null 을 돌려줬을 뿐이다.
* 잘못은 그 null 을 확인하지 않고 바로 getUserId() 를 부른 JSP 쪽에 있다.
DAO가 null을 돌려주는 것 자체는 올바른 설계입니다. "없으면 없다고 알려 준다"는 뜻이니까요. 문제는 받는 쪽이 확인하지 않은 것입니다.
고치는 방법은 간단합니다. if (dto == null) { response.sendRedirect("error.jsp"); return; } 이 한 줄을 getUser 호출 바로 뒤에 넣으면 됩니다.
지금은 목록의 링크를 눌러서만 들어오니 이 상황이 안 생깁니다. 하지만 주소를 직접 치거나, 다른 사람이 이미 삭제한 회원의 링크를 누르면 바로 터지죠. "정상 경로로만 들어온다"는 가정이 웹에서는 잘 깨진다는 것이 이 실험의 교훈입니다.
참고로 자바 14부터는 "because 무엇이 null인지"까지 알려 주는 친절한 메시지가 나옵니다 — because "dto" is null처럼요. → 📖 NullPointerException
12번 정리 — 핵심 정리
여러 행이면 List<Dto> + while, 한 행이면 Dto + if.
못 찾았을 때는 null을 돌려준다 — 그래서 받는 쪽이 반드시 확인해야 한다.
ResultSet도 자원이므로 중첩 try-with-resources로 함께 닫는다.
UPDATE의 WHERE에 들어가는 ?가 마지막 번호다 — SET 뒤부터 세기 때문.
? 순서와 set 순서가 어긋나면 에러 없이 엉뚱한 칸에 값이 들어간다.
UPDATE·DELETE에서 WHERE를 빠뜨리면 전체 행이 바뀐다 — 가장 무서운 실수.
executeUpdate()가 돌려준 행 수가 0이면 조건에 맞는 행이 없었다는 뜻이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
한 건 조회에 while 대신 if를 쓰는 이유는?
결과가 최대 한 행뿐이기 때문입니다. WHERE userid = ?로 기본키를 집었으니 0행 아니면 1행이죠. while을 써도 동작은 같지만, if를 쓰면 "여기는 한 건만 온다"는 의도가 코드에 드러납니다. 읽는 사람에게 주는 정보가 다릅니다.
UPDATE 문의 물음표 순서를 정할 때 기준은 무엇인가?
SQL 문자열에서 ?가 나타나는 순서입니다. 절의 이름과는 상관없어요. SET addr=?, mobile1=?, mobile2=?, height=? WHERE userid=?이면 WHERE의 것이 5번이 됩니다. 여기서 순서를 틀리면 에러 없이 엉뚱한 칸이 바뀌므로 특히 조심해야 합니다.
💼 실무·코딩테스트에서는CRUD를 손으로 한 번 만들어 보는 것은 실무에서 반드시 거치는 통과의례입니다. 그 뒤에는 MyBatis·JPA가 이 반복을 대신해 주지만, "없으면 null이냐 예외냐" 같은 설계 판단은 여전히 사람 몫이에요. 요즘 자바에서는 Optional<userDto>로 돌려줘서 받는 쪽이 null 확인을 잊지 못하게 강제하는 방식도 많이 씁니다.
13
처리 페이지와 화면 이동 — sendRedirect와 location.href
sendRedirectlocation.hrefalert처리 페이지
한 줄 요약폼을 받아 DB 작업만 하고 화면은 없는 페이지(처리 페이지)를 따로 둔다. 일이 끝나면 서버가 보내는 response.sendRedirect()나 브라우저가 옮기는 location.href로 다음 화면으로 넘긴다.
쉽게 말하면userInsert.jsp 같은 페이지는 화면이 목적이 아닙니다. 값을 받아 DB에 넣고, 성공했으면 목록으로 · 실패했으면 에러 화면으로 보내는 것이 전부죠. 이런 걸 처리 페이지라고 부르고, 앞으로 배울 컨트롤러의 원시적인 형태입니다. 화면을 그리는 페이지와 일을 하는 페이지를 나누기 시작한 것이니까요.
JSP두 가지 이동 방법이 한 프로젝트에 다 나온다
<%-- ① sendRedirect — 서버가 브라우저에게 "저기로 가라"고 응답한다 --%>
<%
boolean isS = dao.deleteUser(userId);
if (isS) {
response.sendRedirect("userList.jsp"); // 302 응답 + Location 헤더
} else {
response.sendRedirect("error.jsp");
}
%>
<%-- ② 자바스크립트 — 화면을 먼저 보낸 뒤 브라우저가 스스로 옮긴다 --%>
<%
if (isS) {
%>
<script type="text/javascript">
alert("회원정보를 수정했습니다.!!"); // ← 안내를 띄우고 나서
location.href = "userDetail.jsp?userid=<%=userId%>"; // 옮긴다
</script>
<%
} else {
response.sendRedirect("error.jsp");
}
%>
<%-- JSP 안에서 자바가 자바스크립트 문자열을 만들어 내는 구조.
<%=userId%> 는 서버에서 값으로 바뀌어 브라우저에 도착한다 --%>
실행 결과두 방식이 실제로 어떻게 다른지 응답을 직접 확인 먼저 예측 → 펼쳐서 확인
[① sendRedirect — userDel.jsp?userid=KKH]
HTTP/1.1 302
Location: userList.jsp
Content-Length: 0 ← 화면 내용이 아예 없다
[② 자바스크립트 — userInsert.jsp 성공 시]
HTTP/1.1 200
Content-Length: 534 ← HTML 을 한 번 내려보낸다
<body>
<script type="text/javascript">
alert("회원 추가함.!!");
location.href="userList.jsp";
</script>
</body>
* ①은 브라우저가 화면을 그리지 않고 곧장 다음 주소로 간다.
* ②는 화면을 받아 스크립트를 실행해야 이동한다. 그래서 alert 를 띄울 수 있다.
가장 큰 차이는 "사용자에게 뭔가 보여 줄 수 있느냐"입니다. sendRedirect는 화면 없이 바로 넘어가서 빠르고 깔끔하지만 "수정했습니다" 같은 안내를 띄울 수 없죠. 자바스크립트 방식은 안내를 띄울 수 있는 대신 화면을 한 번 받아야 합니다.
주의할 점 — sendRedirect 뒤에도 아래 코드는 계속 실행됩니다. "보내라"고 예약만 한 것이지 메서드를 끝내는 게 아니에요. 그래서 return;을 붙이는 습관이 필요하고, 안 그러면 이미 커밋된 응답에 대해... 같은 IllegalStateException을 만나게 됩니다.
그리고 둘 다 결국 '다시 요청'입니다. 그래서 등록 처리 뒤에는 반드시 이동시켜야 해요 — 그냥 화면을 그려 버리면 사용자가 새로고침할 때마다 같은 데이터가 또 등록됩니다.
13번 정리 — 핵심 정리
처리 페이지는 화면이 아니라 일을 하는 것이 목적이다 — 컨트롤러의 원시 형태.
response.sendRedirect("주소") — 서버가 302 + Location으로 응답해 브라우저를 보낸다.
location.href="주소" — 화면을 받은 뒤 브라우저가 스스로 이동한다.
alert로 안내를 띄우려면 자바스크립트 방식이어야 한다(리다이렉트는 화면이 없다).
sendRedirect 뒤에도 코드는 계속 실행된다 — return;을 붙이자.
등록·수정 처리 뒤에는 반드시 다른 주소로 이동시킨다 — 안 그러면 새로고침으로 중복 등록된다.
리다이렉트는 새 요청이라 request에 담아 둔 값은 사라진다 — forward와의 비교는 → 🖥️ 23(서블릿) · 🖥️ 50(스프링).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
등록 처리 뒤에 화면을 바로 그리지 않고 굳이 다른 페이지로 보내는 이유는?
새로고침하면 등록이 또 실행되기 때문입니다. 브라우저는 새로고침할 때 직전 요청(= POST 등록)을 그대로 다시 보내거든요. 처리 후 다른 주소로 이동시켜 두면 새로고침해도 그 화면만 다시 그려집니다. 이 방식을 PRG(Post-Redirect-Get) 패턴이라 부릅니다.
sendRedirect로는 alert 안내를 띄울 수 없는 이유는?
보낼 화면이 없기 때문입니다. sendRedirect는 본문 없이 "저 주소로 가라"는 응답만 보내고(Content-Length: 0), 브라우저는 그걸 보자마자 다음 주소를 요청합니다. 자바스크립트가 실행될 화면 자체가 없는 것이죠.
💼 실무·코딩테스트에서는실무에서는 sendRedirect(PRG 패턴)가 기본이고, 안내 메시지는 이동한 페이지에서 한 번만 보여 주는 방식(플래시 메시지)으로 처리합니다. 스프링의 RedirectAttributes.addFlashAttribute가 정확히 그 역할이에요. alert로 안내하는 방식은 학습용으로는 직관적이지만 실무 UI에서는 거의 쓰지 않습니다.
14
한글이 깨지는 자리 — request.setCharacterEncoding
한글 깨짐setCharacterEncodingUTF-8인코딩
한 줄 요약POST로 온 한글을 제대로 읽으려면 request.setCharacterEncoding("UTF-8")을 값을 꺼내기 전에 불러야 한다. 다만 이 수업의 Tomcat 10.1 환경에서는 없어도 UTF-8로 읽혔다 — 그래도 쓰는 이유가 있다. (GET 주소의 한글과 POST 본문의 한글은 정하는 곳이 다르다.)
쉽게 말하면"POST에는 무조건 setCharacterEncoding을 써라"고 배우는데, 막상 빼 보면 멀쩡히 잘 됩니다. 그래서 "왜 쓰지?" 싶어지죠. 직접 실험해서 언제 깨지고 언제 안 깨지는지 확인해 보면 답이 나옵니다. 결론부터 말하면 — 지금 환경에서는 안 깨지지만, 그건 서버 쪽 설정이 대신 해 주고 있기 때문입니다.
어디의 한글
무엇이 인코딩을 정하나
기본값
GET — 주소의 ?addr=충북
톰캣 Connector의 URIEncoding(conf/server.xml)
Tomcat 8부터 UTF-8
POST — 요청 본문
request.setCharacterEncoding(), 또는 web.xml의 <request-character-encoding> 같은 서버·앱 설정, 요청 헤더의 charset
서블릿 규격의 기본은 ISO-8859-1. 서버나 앱이 따로 설정해 두었으면 그 값 — 이 수업 환경(Tomcat 10.1)에서는 호출 없이도 UTF-8로 읽혔다(아래 [B])
JSP값을 꺼내기 '전에' 불러야 한다
<%
// ① 반드시 getParameter 보다 먼저! 한 번이라도 값을 꺼낸 뒤에는 효과가 없다
request.setCharacterEncoding("UTF-8");
// ② 그 다음에 꺼낸다
String addr = request.getParameter("addr"); // "충북"
%>
<%-- 순서를 바꾸면 앞의 값은 이미 잘못 해석된 뒤다
String addr = request.getParameter("addr"); // 여기서 이미 결정됨
request.setCharacterEncoding("UTF-8"); // 늦었다
--%>
<%-- GET(주소창) 방식은 이 설정과 무관하다.
톰캣의 conf/server.xml 에서 URIEncoding 으로 정하며, 톰캣 8 이후 기본이 UTF-8 이다 --%>
실행 결과같은 한글을 세 조건으로 보내 직접 비교 (Tomcat 10.1 실측) 먼저 예측 → 펼쳐서 확인
[B]가 중요합니다 — 안 써도 잘 됩니다. 이 환경의 톰캣 설정이 POST 본문도 UTF-8로 읽도록 되어 있기 때문이에요. 그래서 지금 환경에서 이 줄을 지워도 티가 안 납니다.
그럼에도 쓰는 이유는 [C]입니다. 요청이 다른 인코딩이라고 알려 오면 그쪽이 우선되어 2글자가 6글자로 변합니다. 그 상태로 CHAR(2) 컬럼에 넣으면 Data too long으로 거부당하죠. "한글이 깨진다"는 증상이 엉뚱하게 DB 에러로 나타나는 전형적인 경우입니다.
게다가 POST 본문의 서블릿 규격 기본값은 ISO-8859-1이라, 그런 설정이 없는 서버(옛 버전이나 설정이 다른 서버)에서는 같은 코드가 전부 깨집니다. "내 컴퓨터에선 되는데 서버에선 깨져요"의 단골 원인이고, 한 줄로 환경 차이를 없앨 수 있으니 쓰는 편이 낫습니다.
정리 — 지금은 없어도 되지만, 있으면 환경이 바뀌어도 안전합니다. "왜 쓰는지 모르고 쓰는 것"과 "알고 쓰는 것"의 차이가 여기 있습니다.
14번 정리 — 핵심 정리
request.setCharacterEncoding("UTF-8")은 값을 꺼내기 전에 불러야 한다 — 나중에는 효과가 없다.
POST 본문에만 적용된다. GET(주소창)은 톰캣 설정(URIEncoding)이 따로 정한다.
GET 주소의 한글은 URIEncoding이 정하고 Tomcat 8부터 기본 UTF-8이다.
POST 본문은 서블릿 규격 기본이 ISO-8859-1이고, 서버·앱 설정이 있으면 그 값을 따른다 — 이 수업 환경에서는 없어도 UTF-8로 읽혔지만, 설정이 다른 서버에서는 깨진다. 그래서 직접 불러 두는 편이 안전하다.
한글 한 글자가 UTF-8에서는 3바이트라, 잘못 해석하면 2글자가 6글자가 된다.
글자 수가 늘어나면 CHAR(2) 같은 컬럼에서 Data too long으로 거부된다.
인코딩은 화면(contentType)·요청(setCharacterEncoding)·DB(characterEncoding) 세 군데를 모두 UTF-8로 맞춰야 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
setCharacterEncoding을 getParameter 뒤에 쓰면 왜 소용없나?
값을 꺼내는 순간 본문 해석이 끝나 버리기 때문입니다. 첫 getParameter 호출에서 톰캣이 본문 전체를 그때의 인코딩으로 읽어 저장해 두거든요. 그 뒤에 인코딩을 바꿔도 이미 해석된 결과는 되돌아가지 않습니다. 그래서 파일의 맨 위에 두는 것이 관례입니다.
한글이 깨졌을 뿐인데 왜 DB에서 'Data too long' 에러가 나나?
글자 수가 늘어나기 때문입니다. UTF-8 한글 한 글자는 3바이트인데, 이걸 1바이트씩 끊어 읽으면 "충북"(2글자)이 6글자가 됩니다. addr은 CHAR(2)라 6글자를 받아 줄 수 없어 거부하죠. 증상(DB 에러)과 원인(인코딩)이 멀리 떨어져 있어 찾기 어려운 유형입니다.
💼 실무·코딩테스트에서는한글 깨짐은 신입 개발자가 가장 많이 부딪히는 문제이고, 원인이 화면·요청·DB·파일 저장 인코딩 중 어디인지 나눠서 좁혀 가는 능력이 곧 실력입니다. 요즘 스프링 부트는 CharacterEncodingFilter가 기본으로 붙어 있어 이 문제가 거의 안 나지만, 레거시 프로젝트에서는 여전히 첫 번째 관문입니다. 이 한 줄을 JSP마다 쓰지 않고 필터 하나로 모으는 방법은 → 🖥️ 30. 인코딩 Filter
15
구매 목록과 외래키 — 지울 수 없는 회원
BuyDtoBuyDao외래키무결성 제약
한 줄 요약회원 CRUD를 그대로 구매 목록에 한 번 더 적용했다. 다른 점은 buyTbl.userID가 userTbl을 참조하는 외래키라는 것 — 그래서 구매 이력이 있는 회원은 삭제되지 않는다.
쉽게 말하면같은 구조를 두 번째 만들어 보면 손에 붙습니다. DTO 만들고, DAO에 CRUD 다섯 개 만들고, 목록·상세·등록·수정·삭제 JSP를 만드는 흐름이 테이블만 바뀐 똑같은 일이라는 게 보이죠. 다만 구매 테이블에는 회원을 가리키는 외래키가 있어서, 두 테이블이 서로 얽힐 때 무슨 일이 생기는지를 처음으로 겪게 됩니다.
JSPBuyDao — 구조는 같고 식별자만 다르다
// 회원은 userId(CHAR), 구매는 num(INT)이 기본키다
public BuyDto getBuy(int num) { ... }
public boolean updateBuy(BuyDto dto) {
String sql = " UPDATE BUYTBL "
+ " SET price = ?, amount = ? " // 가격과 수량만 수정 대상
+ " WHERE num = ? ";
psmt.setInt(1, dto.getPrice());
psmt.setInt(2, dto.getAmount());
psmt.setInt(3, dto.getNum());
...
}
public boolean deleteBuy(int num) { ... }
// 화면 쪽도 식별자만 바뀐다
<td><a href="buyDetail.jsp?num=<%=dto.getNum()%>"><%=dto.getProudName()%></a></td>
function delBuy(num) {
if (confirm("정말 이 구매 내역을 삭제하겠습니까?")) {
location.href = "buyDel.jsp?num=" + num;
}
}
/* 테이블 구조 (sqlDB 실습에서 만든 그대로)
buyTbl.userID CHAR(8) NOT NULL,
FOREIGN KEY (userID) REFERENCES userTbl(userID) ← 이 한 줄이 오늘의 주인공 */
실행 결과구매 이력이 있는 회원과 없는 회원을 각각 삭제해 보면 먼저 예측 → 펼쳐서 확인
[1] 구매 이력이 있는 회원 삭제 — userDel.jsp?userid=KBS
HTTP 302 → Location: error.jsp ← 실패, 에러 화면으로
(콘솔에 찍힌 진짜 이유)
Cannot delete or update a parent row: a foreign key constraint fails
(`hk`.`buytbl`, CONSTRAINT `1` FOREIGN KEY (`userID`)
REFERENCES `usertbl` (`userID`))
[2] 구매 이력이 없는 회원 삭제 — userDel.jsp?userid=KKH
HTTP 302 → Location: userList.jsp ← 성공, 목록으로
삭제 후 회원: BBK, JYP, KBS, LSG, SSK ← KKH 만 빠졌다
[3] 없는 회원 아이디로 구매 등록 — buyInsert.jsp (userId=NONE)
HTTP 302 → Location: error.jsp
Cannot add or update a child row: a foreign key constraint fails
[4] 정상 등록 — userId=JYP
HTTP 302 → Location: buyList.jsp
+-----+--------+----------+-----------+-------+--------+
| num | userID | prodName | groupName | price | amount |
| 7 | JYP | 키보드 | 전자 | 50 | 2 |
외래키가 데이터를 지켜 주고 있습니다. 구매 기록이 남아 있는 회원을 지우면 주인 없는 구매 내역이 되니까, DB가 아예 거부한 것이죠. 반대로 [3]처럼 없는 회원의 구매도 넣지 못합니다. 프로그램이 아무리 실수해도 DB가 마지막 방어선이 되어 준다는 걸 직접 확인한 셈입니다. → 🗄️ 31. 제약조건
그런데 화면은 "시스템 오류입니다. 관리자에게 문의하세요"라고만 말합니다. 진짜 이유인 "구매 이력이 있어 삭제할 수 없습니다"는 콘솔에만 찍히고 사용자에게는 전달되지 않죠. DAO가 원인을 false 하나로 뭉개 버렸기 때문입니다. → 🖥️ 04. DAO
실무라면 이걸 어떻게 풀까요? 방법은 세 가지입니다 — ① 삭제 전에 구매 이력을 조회해 미리 안내하거나, ② 예외를 위로 던져 화면에서 사유별로 다르게 처리하거나, ③ 진짜로 지우지 않고 '탈퇴함' 표시만 남기는(소프트 삭제) 방식입니다. 실무에서는 ③번이 가장 흔합니다 — 기록은 지우면 안 되니까요.
15번 정리 — 핵심 정리
같은 CRUD 구조를 테이블만 바꿔 반복하는 것이 업무 시스템의 기본 골격이다.
외래키는 "짝이 없는 데이터를 못 만들게" 하는 DB의 규칙이다.
부모(회원)를 지우려는데 자식(구매)이 남아 있으면Cannot delete or update a parent row.
없는 부모를 가리키는 자식을 넣으려 하면Cannot add or update a child row.
제약은 프로그램이 아니라 DB가 지킨다 — 프로그램이 여러 개여도 규칙은 한 곳이다.
boolean만 돌려주면 실패 사유를 화면에 전할 수 없다 — 안내가 뭉뚱그려진다.
실무에서는 회원을 진짜로 지우지 않고 '탈퇴' 표시만 남기는 경우가 많다(기록 보존).
ON DELETE CASCADE를 걸면 자식까지 함께 지워지지만, 구매 기록에는 위험한 선택이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
회원을 지웠더니 error.jsp가 떴다. 사용자에게 무엇을 알려 줘야 하나?
"구매 이력이 있는 회원은 삭제할 수 없습니다"입니다. "시스템 오류"는 사용자가 아무것도 할 수 없는 안내라 최악이죠. 지금 구조에서는 DAO가 false 하나로 사유를 뭉개서 이 안내를 만들 수 없습니다. 삭제 전에 구매 이력을 먼저 조회하거나 예외를 위로 던지는 방식으로 고쳐야 합니다.
ON DELETE CASCADE를 걸면 편할 텐데, 구매 기록에 쓰면 왜 위험한가?
회원 한 명을 지우는 순간 그 사람의 구매 기록이 전부 사라지기 때문입니다. 구매 내역은 매출·정산·세무의 근거라 함부로 없애면 안 되죠. 편의를 위해 건 옵션이 데이터를 통째로 날리는 경우라, 실무에서는 기록성 테이블에 CASCADE를 거의 걸지 않습니다.
💼 실무·코딩테스트에서는"지울 수 있는 데이터와 지우면 안 되는 데이터"를 구분하는 감각은 실무에서 꽤 중요합니다. 주문·결제·로그처럼 기록으로 남아야 하는 것은 대개 지우지 않고 상태만 바꾸죠(소프트 삭제). 외래키 위반 에러는 설계가 제대로 되어 있다는 신호이기도 해서, "에러가 나니까 제약을 빼자"는 거의 언제나 잘못된 방향입니다.
16
지금 만든 구조의 이름 — MVC1과 그 한계
MVC1MVC2관심사 분리컨트롤러
한 줄 요약JSP가 화면도 그리고 로직도 실행하는 지금 구조를 MVC1이라 부른다. 빨리 만들 수 있지만 한 파일에 두 가지 일이 섞여 있어, 다음 단계인 MVC2(서블릿이 흐름을 맡고 JSP는 화면만)로 넘어가게 된다.
쉽게 말하면앞에서 만든 userUpdate.jsp를 다시 보면 HTML · 자바 · 자바스크립트 · SQL 호출이 한 파일에 다 들어 있습니다. 지금은 파일이 작아서 괜찮지만, 기능이 늘면 어디를 고쳐야 할지 찾기가 어려워집니다. "이 화면 색만 바꾸려는데 DB 코드가 같이 보이는" 상황이 되는 거죠. 그래서 다음 단계가 MVC2입니다 — 지금 불편한 이유를 알고 넘어가야 그 구조가 왜 필요한지 이해됩니다.
JAVA지금 구조와 다음 구조
/* ===== 지금 (MVC1) : 브라우저 → JSP → DAO → DB ===== */
userList.jsp 화면 + 조회 로직
userDetail.jsp 화면 + 조회 로직
userUpdate.jsp 로직만 (화면 없음) ← "처리 페이지"
userDel.jsp 로직만 (화면 없음)
userInsert.jsp 로직만 (화면 없음)
userInsertFrom.jsp 화면만
→ 화면용 JSP 와 처리용 JSP 가 섞여 있고, 주소창에 .jsp 가 그대로 드러난다
/* ===== 다음 (MVC2) : 브라우저 → 서블릿(컨트롤러) → JSP(화면) ===== */
Controller (서블릿) 요청을 받아 무엇을 할지 정한다
↓
Service / DAO 실제 일을 한다
↓
JSP (View) 결과를 화면으로만 그린다 ← 자바 코드가 없다
→ JSP 는 WEB-INF/views 안에 두어 직접 접근을 막는다 (→ 🖥️ 02번 카드)
→ 화면에는 EL(${user.name}) 과 JSTL(<c:forEach>) 만 남는다
실행 결과MVC1 화면에서 null을 만났을 때 생기는 두 가지 증상 먼저 예측 → 펼쳐서 확인
[1] 없는 회원의 상세를 열면 — 확인 코드가 없을 때
목록 → 상세를 보다가, 그 회원을 지운 뒤 뒤로가기로 상세를 다시 열면
HTTP 500 – 내부 서버 오류
java.lang.NullPointerException:
Cannot invoke "userDto.getUserId()" because "dto" is null
→ 값을 쓰기 전에 확인하고 error.jsp 로 보낸다 (userDetail.jsp)
if (dto == null) {
response.sendRedirect("error.jsp");
return; // sendRedirect 는 아래 코드를 멈추지 않는다
}
확인 코드를 넣은 뒤: 302 → error.jsp
[2] NULL 이 화면에 그대로 나온다
buyList.jsp 의 그룹이름 칸:
<td>null</td> ← groupName 이 NULL 인 운동화
(<%=dto.getGroupName()%> 는 null 을 문자열 "null" 로 찍는다)
[1]은 평범하게 쓰다가 자바 에러 화면을 만나는 경우입니다. 목록에서 회원을 지우고 뒤로가기를 누르는 것만으로 HTTP 500이 뜨죠. DAO는 잘못이 없어요 — 못 찾았으니 약속대로 null을 돌려줬을 뿐입니다. 받는 쪽이 확인하지 않은 것이 문제입니다.
[2]는 에러는 아니지만 화면에 "null"이라는 개발 용어가 새어 나옵니다.<%=null%>이 "null"을 찍는 문제는 EL의 ${dto.groupName}이 빈칸을 찍는 것과 비교하면 이해가 쉽습니다 — MVC2에서 화면을 EL·JSTL로 바꾸는 이유 중 하나입니다.
16번 정리 — 핵심 정리
지금 구조 = MVC1(Model 1) — 요청을 JSP가 받아 화면과 로직을 함께 맡는다. 01과 02는 모두 MVC1이고, 02는 JSP 컨트롤러(boardController.jsp)로 분기만 한 곳에 모은 것이다(web-20).
다음 구조 = MVC2 — 서블릿(컨트롤러)이 흐름을, JSP는 화면만 맡는다.
MVC2에서는 JSP를 WEB-INF/views에 두어 직접 접근을 막는다. → 🖥️ 02. WEB-INF
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
지금 구조에서 '화면 디자인만 바꾸고 싶다'면 무엇이 불편한가?
열어 보면 자바 코드와 SQL 호출이 함께 보인다는 것입니다. 디자인만 고치려는 사람이 DAO 호출 코드를 건드려 깨뜨릴 위험이 있고, 반대로 로직을 고치다 표 태그를 깨뜨리기도 쉽죠. MVC2에서 JSP에 자바가 없으면 디자이너가 혼자 열어서 고칠 수 있습니다.
<%=dto.getGroupName()%>가 null을 찍는 것이 왜 문제인가? EL은 어떻게 다른가?
사용자에게 "null"이라는 글자가 그대로 보이기 때문입니다. 비어 있다는 뜻인데 개발 용어가 화면에 노출되는 거죠. EL의 ${dto.groupName}은 null일 때 아무것도 출력하지 않아 빈칸으로 나옵니다. 화면용 문법이라 화면에 맞게 동작하는 것입니다.
💼 실무·코딩테스트에서는MVC는 면접 단골 주제입니다. "MVC1과 MVC2의 차이"를 물으면 "흐름 제어를 JSP가 하느냐 서블릿이 하느냐"가 핵심이에요. 그리고 실무에서 더 중요한 건 "왜 나누는가"입니다 — 지금처럼 안 나눠 보고 불편함을 겪은 경험이 있으면 그 답을 외우지 않고 말할 수 있습니다.
정리 작업 메모
고침 — dto == null 확인이 없어 HTTP 500이 나던 곳 6군데(userDetail.jsp 없는 회원·파라미터 없음, buyDetail.jsp 없는 번호·파라미터 없음·숫자 아님, buyDel.jsp 숫자 아님)를 모두 error.jsp로 보내게 했다. 정상 경로 8개는 그대로 200.
고침 — userInsert.jsp 응답 끝에 짝 없이 남은 %>가 화면에 찍히던 것을 지웠다.
고침 — userList.jsp 헤더 <tr>을 닫지 않아 행이 중첩되고, 4열 표에 colspan="5"였던 것을 </tr> 추가·colspan="4"로 맞췄다.
일부러 남김 — <%=null%>이 "null"을 찍는 문제(EL과 비교할 때 다룸), 외래키 위반·아이디 중복·DB 연결 실패가 모두 error.jsp로 뭉개지는 문제(DAO가 boolean만 돌려줌), 화면 없는 처리 페이지가 <html><body> 껍데기를 가진 문제(컨트롤러 분리 때 사라짐).
정리 중 주석에 <%=dto.getUserId()%>를 적었다가 그 안의 %>가 스크립틀릿을 끝내 페이지가 깨진 일이 있었다 — JSP는 // 주석이어도 %>를 보면 자바 영역을 닫는다. → 🖥️ 09. JSP 3형제
17
DAO 상속으로 중복 없애기 — DataBase 부모 클래스
상속extendsDataBase중복 제거
한 줄 요약여러 DAO가 똑같이 반복하던 드라이버 로딩·연결 코드를 DataBase라는 부모 클래스로 뺐다. HkDao extends DataBase로 물려받으면 getConnection() 한 줄로 연결을 얻는다.
쉽게 말하면앞 프로젝트(01_userboard_sepa)의 UserDao·BuyDao를 다시 보면, 메서드마다 url·user·password를 똑같이 반복해서 썼습니다. 비밀번호를 바꾸려면 10곳을 찾아다녀야 했죠. 이번에는 그걸 한곳으로 모았습니다 — "드라이버 로딩과 연결"이라는 공통 작업만 하는 부모 클래스를 만들고, DAO는 그걸 상속받아 쓰는 식으로요.
JAVADataBase.java — 공통 작업만 담당하는 부모
package com.hk.board.datasource;
// JDBC 1단계(드라이버 로딩)와 2단계(연결)만 전담하는 부모 클래스
public class DataBase {
// 객체가 생성될 때(new) 가장 먼저 실행 — 1단계
public DataBase() {
try {
Class.forName("org.mariadb.jdbc.Driver");
System.out.println("1단계: 드라이버 로딩 성공");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
// 2단계 — 연결 정보는 이제 여기 한 곳에만 있다
public Connection getConnection() throws SQLException {
String url = "jdbc:mariadb://localhost:3306/hk";
String user = "root";
String password = ""; // ← 비밀번호를 바꿀 곳은 이제 딱 한 군데
return DriverManager.getConnection(url, user, password);
}
}
JAVAHkDao.java — 상속받아 물려쓴다
// extends DataBase — 부모의 getConnection()을 그대로 물려받는다
public class HkDao extends DataBase {
public List<HkDto> getAllList() {
List<HkDto> list = new ArrayList<>();
String sql = "SELECT SEQ, ID, TITLE, CONTENT, REGDATE FROM HKBOARD ORDER BY REGDATE DESC";
// url·user·password 를 여기서 다시 쓰지 않는다.
// getConnection() 하나로 부모가 해 둔 일을 그대로 받는다.
try (Connection conn = getConnection();
PreparedStatement psmt = conn.prepareStatement(sql)) {
try (ResultSet rs = psmt.executeQuery()) {
while (rs.next()) {
HkDto dto = new HkDto();
dto.setSeq(rs.getInt(1));
dto.setId(rs.getString(2));
dto.setTitle(rs.getString(3));
dto.setContent(rs.getString(4));
dto.setRegDate(rs.getDate(5));
list.add(dto);
}
}
} catch (SQLException e) { e.printStackTrace(); }
return list;
}
}
실행 결과MainTest 실행 — 부모의 생성자가 자동으로 먼저 돈다 먼저 예측 → 펼쳐서 확인
=== 1. 글 추가하기 테스트 ===
1단계: 드라이버 로딩 성공 ← HkDao를 new 하는 순간, 부모 DataBase() 가 먼저 실행된다
-> 글 추가 성공!
true
=== 2. 글 목록 조회 테스트 ===
1단계: 드라이버 로딩 성공 ← HkDao를 또 new 했으니 부모 생성자가 또 돈다
HkDto [seq=1, id=hkadmin, title=공지사항입니다, ...]
HkDto [seq=2, id=kim, title=안녕하세요, ...]
HkDto [seq=3, id=hkadmin, title=테스트 글 등록, ...]
HkDao dao = new HkDao();라고만 썼는데 "1단계: 드라이버 로딩 성공"이 찍힙니다.HkDao는 자기 생성자를 안 만들었는데도, 자바가 부모(DataBase)의 생성자를 자동으로 먼저 호출하기 때문입니다. 이것이 상속의 규칙입니다 — 자식 객체를 만들면 부모의 초기화가 항상 먼저 끝나 있습니다. → 🧱 11. 상속과 super
테스트를 두 번 실행하면 두 번 다 찍히는 것도 같은 이유입니다. MainTest가 HkDao를 두 번 new했으니, 그때마다 부모 생성자가 다시 돕니다. 연결 자체는 매번 새로 여닫히고, "드라이버가 로딩되어 있다"는 상태만 재사용되는 구조입니다.
17번 정리 — 핵심 정리
여러 클래스가 반복하는 공통 작업은 부모 클래스로 뺀다 — 여기서는 드라이버 로딩·연결.
class HkDao extends DataBase — HkDao는 DataBase의 모든 것을 물려받는다.
자식 객체를 생성하면 부모의 생성자가 먼저 자동으로 실행된다.
물려받은 메서드는 this.나 다른 표시 없이 그냥 이름만 불러 쓴다 — getConnection()처럼.
중복이 사라지는 만큼 고칠 곳도 줄어든다 — 비밀번호를 바꿀 곳이 10곳에서 1곳으로.
다만 연결을 매번 새로 여닫는 것은 그대로다 — 상속은 코드 중복을 없앨 뿐, 연결 재사용은 다른 주제(Connection Pool)다.
상속은 "is-a"(~는 ~의 한 종류다) 관계에 쓴다 — "HkDao는 DataBase의 한 종류"라기보다 "DataBase 기능을 빌려 쓴다"에 가까워, 실무에서는 상속 대신 DataSource를 필드로 갖는 방식(합성)을 더 권장한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
HkDao에 생성자를 하나도 안 만들었는데 '1단계: 드라이버 로딩 성공'이 찍히는 이유는?
부모 클래스 DataBase의 생성자가 자동으로 먼저 실행되기 때문입니다. 자바는 자식 객체를 만들 때 부모의 초기화를 항상 먼저 끝내 둡니다 — 자식이 부모의 상태 위에서 시작해야 하기 때문이죠. HkDao가 명시적으로 super()를 쓰지 않아도 컴파일러가 자동으로 넣어 줍니다.
DB 접속 정보를 부모 클래스로 모으면 실무에서 어떤 점이 편한가?
바꿀 곳이 한 곳으로 줄어듭니다. 개발 서버에서 운영 서버로 옮길 때, 또는 비밀번호를 바꿀 때 모든 DAO를 뒤지지 않고 DataBase 하나만 고치면 됩니다. 다만 실무에서는 이 정보를 클래스에 하드코딩하지 않고 설정 파일이나 커넥션 풀에서 가져오는 것이 표준입니다.
💼 실무·코딩테스트에서는"똑같은 코드가 세 번 이상 반복되면 하나로 묶어라"는 실무의 기본 원칙입니다. 다만 상속을 만능으로 쓰면 클래스 사이가 너무 단단히 얽히는 문제가 생겨서, 실무에서는 이 자리에 커넥션 풀(HikariCP 등)을 주입받는 방식을 더 많이 씁니다. 지금은 "공통 작업을 어디로 모을까"라는 감각을 상속으로 먼저 연습하는 단계입니다.
18
컨트롤러의 시작 — command 분기와 forward
컨트롤러commandforwardrequest scopesetAttribute
한 줄 요약요청마다 파일을 따로 만들던 앞의 회원 관리 예제와 달리, 하나의 JSP가 command 파라미터로 "무엇을 할지"를 정하는 구조가 등장했다. 화면으로 값을 넘길 때는 response.sendRedirect 대신 request.setAttribute + pageContext.forward를 쓴다.
쉽게 말하면앞의 회원 관리 예제에서는 할 일마다 파일이 하나였습니다 — 목록은 userList.jsp, 상세는 userDetail.jsp처럼요. 이 게시판 예제에서는 boardController.jsp 딱 하나가 문지기 역할을 합니다. ?command=boardlist로 오면 목록을 보여 주고, ?command=boardinsert로 오면 등록을 하는 식이죠. 이게 바로 뒤에서 배울 서블릿 컨트롤러(MVC2)의 예행연습입니다.
JAVAboardController.jsp — 요청을 command로 분기한다
<%
// 1단계: command 값 받기 — 어떤 요청인지 확인
String command = request.getParameter("command");
if (command == null) command = ""; // 없이 열리면 아래 분기가 전부 거짓이 되게
// 2단계: DAO 준비
HkDao dao = new HkDao();
// 3단계: 요청 분기 — 이 한 파일이 여러 화면의 창구 역할을 한다
if (command.equalsIgnoreCase("boardlist")) {
List<HkDto> list = dao.getAllList();
// request scope 에 담는다 — 이 요청이 끝날 때까지만 살아 있는 상자
request.setAttribute("list", list);
// sendRedirect가 아니라 forward! (차이는 아래 실행 결과에서)
pageContext.forward("boardlist.jsp");
} else if (command.equalsIgnoreCase("boardinsertform")) {
// 글쓰기 폼으로 이동 — 넘길 값이 없으니 sendRedirect
response.sendRedirect("boardInsertForm.jsp");
} else if (command.equalsIgnoreCase("boardInsert")) {
// 글 등록 처리 — 파라미터를 받아 insertBoard 후 목록 command로 sendRedirect
// ... (이어서 boardDetail · boardUpdate · boardDelete · muldel 분기가 있다)
}
%>
JAVAboardlist.jsp — forward로 넘어온 값을 꺼낸다
<%
// boardController.jsp 가 request scope 에 담아 둔 것을 그대로 꺼낸다.
// getAttribute 는 Object 로 돌려주므로 원래 타입으로 되돌려야(캐스팅) 한다.
List<HkDto> list = (List<HkDto>) request.getAttribute("list");
%>
<table border="1">
<tr><th>번호</th><th>작성자</th><th>제목</th><th>작성일</th></tr>
<%
for (HkDto dto : list) {
%>
<tr>
<td><%=dto.getSeq()%></td>
<td><%=dto.getId()%></td>
<td><%=dto.getTitle()%></td>
<td><%=dto.getRegDate()%></td>
</tr>
<%
}
%>
</table>
실행 결과boardController.jsp?command=boardlist 를 요청한 결과 먼저 예측 → 펼쳐서 확인
[주소창에 남는 것]
요청: http://localhost:8080/02_hkboard_MVC1/boardController.jsp?command=boardlist
화면에 남는 주소: 그대로 boardController.jsp?command=boardlist ← 안 바뀐다!
[브라우저가 받은 HTML]
<table border="1">
<tr><th>번호</th><th>작성자</th><th>제목</th><th>작성일</th></tr>
<tr><td>1</td><td>hkadmin</td><td>공지사항입니다</td><td>2026-09-15</td></tr>
<tr><td>2</td><td>kim</td><td>안녕하세요</td><td>2026-09-15</td></tr>
<tr><td>3</td><td>hkadmin</td><td>테스트 글 등록</td><td>2026-09-15</td></tr>
</table>
[command 없이 그냥 열면]
boardController.jsp → null 방어가 없으면 NullPointerException(500)
(이 코드는 command == null 방어를 넣어 빈 화면으로 조용히 넘어간다)
가장 눈에 띄는 것은 주소창이 안 바뀐다는 점입니다. 분명 boardlist.jsp의 내용이 나왔는데, 주소는 여전히 boardController.jsp?command=boardlist죠. 이게 forward의 특징입니다 — 서버 안에서 다음 페이지로 넘겨줄 뿐이라 같은 요청 하나가 이어지고, 그래서 request.setAttribute로 담은 값도 살아 있습니다. (sendRedirect와의 자세한 비교는 → 🖥️ 23. forward와 redirect, 스프링에서는 → 🖥️ 50. 네 가지 scope는 → 🖥️ 21)
request.getAttribute("list")가 Object를 돌려줘서 (List<HkDto>)로 형변환한 것도 눈여겨볼 부분입니다. 타입을 잘못 캐스팅하면 ClassCastException이 나므로, 담을 때와 꺼낼 때의 타입을 정확히 맞추는 것이 중요합니다.
18번 정리 — 핵심 정리
컨트롤러 = 요청을 받아 "무엇을 할지" 분기하는 창구. 화면을 직접 그리지 않는다.
command 파라미터 값으로 if-else 분기해 여러 기능을 한 파일에서 처리한다.
pageContext.forward("파일") — 서버 안에서 다음 페이지로 넘긴다. 주소창이 안 바뀐다.
response.sendRedirect("파일") — 브라우저에게 새로 요청하라고 시킨다. 주소창이 바뀐다.
request.setAttribute(이름, 값)으로 담고 request.getAttribute(이름)으로 꺼낸다 — request scope.
getAttribute는 Object를 돌려주므로 원래 타입으로 형변환(캐스팅)해야 한다.
request scope는 forward로 이어지는 동안만 산다 — sendRedirect(새 요청)로는 안 넘어간다.
command == null 방어가 없으면, 파라미터 없이 열었을 때 NullPointerException이 난다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
forward와 sendRedirect의 차이를 '주소창'과 'request 데이터' 두 가지로 설명해보세요.
forward는 주소창이 그대로고 request에 담은 값이 이어집니다. 서버 안에서만 다음 페이지로 넘어가는 같은 요청 하나이기 때문이죠. sendRedirect는 주소창이 바뀌고 request가 초기화됩니다. 브라우저가 새 요청을 보내는 것이라, 이전 요청에 담아 둔 값은 사라지고 쿼리스트링이나 세션으로 다시 넘겨야 합니다.
request.getAttribute로 꺼낸 값을 (List<HkDto>)로 형변환하는 이유는?
getAttribute의 반환 타입이 Object이기 때문입니다. request scope는 어떤 타입의 값이든 담을 수 있는 만능 상자라서, 꺼낼 때는 원래 무엇을 넣었는지 내가 알려 줘야 합니다. setAttribute에 넣은 타입과 getAttribute에서 캐스팅하는 타입이 다르면 ClassCastException이 납니다.
💼 실무·코딩테스트에서는command 파라미터로 분기하는 이 패턴을 프론트 컨트롤러(Front Controller) 패턴이라 부릅니다. 스프링 MVC의 DispatcherServlet이 정확히 이 역할을 자동화한 것이에요 — 지금 if-else로 손수 나누는 분기를, 스프링에서는 @RequestMapping 애너테이션 하나로 대신합니다. forward·request scope는 실무에서도 같은 요청 안에서 값을 넘길 때 여전히 그대로 쓰입니다.
19
JSP 실행 과정과 문법 — 선언·실행·표현·지시자·주석
JSPJasper 번역스크립틀릿표현식JSP 주석
한 줄 요약JSP는 Tomcat 안에서 서블릿 자바 소스로 번역 → 컴파일 → 실행되고, 브라우저는 그 실행 결과인 HTML 응답만 받는다. 그 안에서 쓰는 다섯 가지 문법(선언부·실행부·표현부·지시자·JSP 주석)이 각각 번역된 서블릿의 어느 자리에 들어가는지 알면 헷갈리지 않는다.
쉽게 말하면JSP는 조리법, Tomcat은 주방, HTML 응답은 완성된 요리입니다. 손님(브라우저)은 요리만 받지 조리법을 받지 않아요. 그래서 브라우저에서 "페이지 소스 보기"를 해도 자바 코드는 보이지 않고, 자바가 계산해서 찍어 낸 결과 글자만 보입니다. 브라우저가 자바를 실행하는 일은 없습니다.
① 실행 과정 — test.jsp → test_jsp.java → test_jsp.class
Tomcat의 JSP 엔진(Jasper)이 JSP를 서블릿 자바 소스로 번역하고 컴파일해서 서버 JVM에서 실행합니다. 보통 처음 요청될 때나 JSP 파일이 바뀌었을 때 번역·컴파일하고, 이미 준비된 클래스는 다음 요청에 다시 씁니다. 그래서 JSP를 고친 직후 첫 요청만 살짝 느린 것이 정상입니다.
② 다섯 가지 문법이 들어가는 자리
지시자 <%@ page ... %> — 인코딩·import 같은 번역 단계 설정. 실행부(스크립틀릿) <% ... %> — 요청을 처리하는 메서드(_jspService) 안에 그대로 들어가는 자바 문장. 표현부 <%= ... %> — 값을 응답에 출력하는 것으로, 끝에 세미콜론을 붙이지 않는다. 선언부 <%! ... %> — 그 메서드 밖, 서블릿 클래스의 필드·메서드로 들어간다. JSP 주석 <%-- ... --%> — 번역할 때 버려져 응답에 남지 않는다.
③ 선언부 필드는 모든 요청이 함께 쓴다
같은 JSP의 서블릿 객체 하나가 여러 요청을 동시에 처리할 수 있습니다. 그래서 <%! String name; %>처럼 선언부 필드에 방문자별 값을 저장하면 다른 사람 값으로 덮일 수 있어요. 요청마다 다른 값은 스크립틀릿의 지역변수나 알맞은 scope(web-21)에 둡니다. 수업 프로젝트에는 선언부를 쓰는 파일이 없습니다 — 대부분의 JSP가 실행부와 표현부만으로 충분하기 때문입니다.
JSP02_hkboard_MVC1/boardlist.jsp — 지시자·실행부·표현부가 한 파일에
<%-- ← 지시자: 번역할 때 쓰는 설정(import, 인코딩) --%>
<%@page import="com.hk.board.dto.HkDto"%>
<%@page import="java.util.List"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
...
<%
// ← 실행부(스크립틀릿): 요청 처리 메서드 안에서 그대로 실행된다
List<HkDto> list =(List<HkDto>)request.getAttribute("list");
%>
...
<%
for(HkDto dto:list){ // ← 자바 for 문이 열리고
%>
<tr> <%-- ← 이 HTML 이 반복 출력된다 --%>
<td><%=dto.getSeq()%></td> <%-- ← 표현부: 세미콜론 없음 --%>
<td><%=dto.getId()%></td>
...
</tr>
<%
} // ← 여기서 for 문이 닫힌다
%>
JSP02_hkboard_MVC1/boardDetail.jsp — JSP 주석과 HTML 주석
<h1>글 상세보기</h1>
<form action="boardController.jsp" method="post">
<!-- 컨트롤러 분기용 식별자 -->
<input type="hidden" name="command" value="boardUpdate" />
<input type="hidden" name="seq" value="(그 글의 번호)" />
* UPDATE 쿼리가 적힌 JSP 주석은 한 글자도 남지 않는다.
* HTML 주석은 그대로 보인다.
* <%=dto.getSeq()%> 자리에는 자바 코드 대신 계산된 숫자만 찍힌다.
쿼리문을 HTML 주석으로 숨겼다면 누구나 소스 보기로 읽을 수 있었습니다. 서버 내부 정보는 JSP 주석으로 적어야 응답에 실리지 않습니다.
또 하나 조심할 점: HTML 주석은 JSP 실행을 막지 못합니다. <!-- <%=dto.getTitle()%> -->라고 쓰면 표현식은 그대로 실행되고, 그 결과가 HTML 주석 안에 찍혀 나갑니다. JSP 코드를 잠시 꺼 두려면 <%-- --%>를 써야 합니다.
19번 정리 — 핵심 정리
JSP는 서블릿으로 번역 → 컴파일 → 서버에서 실행되고, 브라우저는 HTML 결과만 받는다.
지시자 <%@ %> = 번역 설정, 실행부 <% %> = 요청 처리 메서드 안의 문장.
표현부 <%= %>는 값을 출력하며 세미콜론을 붙이지 않는다.
선언부 <%! %>는 클래스의 필드·메서드가 된다 — 여러 요청이 공유하므로 사용자별 값을 두지 않는다.
JSP 주석은 응답에 남지 않고, HTML 주석은 남는다. HTML 주석 안의 JSP 코드도 실행된다.
스크립틀릿 안에서는 // 주석 속 %>도 자바 영역을 닫아 버린다. → 🖥️ 09. JSP 기초
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
브라우저의 페이지 소스에 자바 코드가 보여야 할까?
아니요. JSP는 서버에서 실행되고, 응답에는 실행 결과만 들어갑니다. 만약 소스 보기에 <%가 그대로 보인다면 그 파일이 JSP로 처리되지 않았다(확장자가 .html이거나 정적 파일로 배포됨)는 신호입니다. 반면 자바스크립트는 응답에 포함되어 브라우저에서 실행되므로 소스에 보이는 게 정상입니다.
방문자 이름을 선언부 필드에 넣으면 왜 위험할까?
같은 서블릿 객체 하나가 여러 요청을 처리할 수 있어서, A가 저장한 이름을 B의 요청이 덮어쓰거나 B 화면에 A의 이름이 나올 수 있기 때문입니다. 선언부 필드는 모든 요청이 함께 쓰는 칸이에요. 요청별 값은 스크립틀릿의 지역변수나 request·session에 둡니다.
<%= dto.getSeq(); %>처럼 세미콜론을 붙이면 어떻게 될까?
번역된 자바 코드가 문법 오류가 됩니다. 표현부의 내용은 out.print( ... )의 괄호 안에 그대로 들어가기 때문에, 세미콜론이 있으면 괄호 안에 문장 끝 기호가 끼어든 꼴이 되어 컴파일에 실패하고 HTTP 500이 납니다.
💼 실무·코딩테스트에서는요즘 실무 JSP에서는 스크립틀릿을 거의 쓰지 않고 EL·JSTL로 화면을 만들고, 새 프로젝트는 Thymeleaf 같은 템플릿이나 프런트엔드 프레임워크를 쓰는 경우가 많습니다. 그래도 "서버에서 실행되고 결과 HTML만 내려간다"는 원리는 모든 서버 렌더링에 공통이에요. 면접에서 "JSP와 서블릿의 관계"를 물으면 "JSP는 서블릿으로 번역되어 실행된다"가 핵심 답입니다. 근거: Tomcat 10.1 Jasper 문서.
20
separation · MVC1 · MVC2 — 이름보다 책임 구분
MVC1MVC2컨트롤러command 분기관심사 분리
한 줄 요약MVC는 데이터 처리(Model)·화면(View)·요청 판단과 이동(Controller)을 누가 맡는지로 구분한다. 01·02는 요청을 JSP가 받으므로 둘 다 MVC1이고, 02는 그 안에서 JSP 컨트롤러(boardController.jsp)로 분기만 한 곳에 모은 단계다. 요청을 서블릿이 받는 순간부터 MVC2(04~)다.
쉽게 말하면식당으로 치면 주방(Model)은 재료와 요리, 홀 직원(Controller)은 주문을 받아 어느 주방으로 보낼지 정하고 손님을 안내, 접시(View)는 요리를 보여 주기만 합니다. 02 프로젝트는 홀 직원을 한 명 세우긴 했는데 그 직원이 아직 JSP라는 앞치마를 입고 있는 상태예요. 앞치마를 벗고 정식 직원(서블릿)이 되면 MVC2입니다. 이름보다 "누가 무엇을 맡는가"가 핵심입니다.
01(_sepa = separation)은 DB 코드를 DAO로, 전달값을 DTO로 분리했지만 화면마다 JSP가 파라미터 처리·DAO 호출·화면 출력을 다 합니다. 02는 여기에 "모든 요청은 boardController.jsp로"라는 규칙을 더해, 요청 판단과 이동을 한 파일에 모았습니다. 그래서 화면 JSP(boardlist.jsp)는 받은 list를 그리는 일에 집중하죠. 다만 컨트롤러가 JSP 파일이고 화면 JSP에도 스크립틀릿이 남아 있어 여전히 MVC1입니다. 서블릿 컨트롤러로 바꾼 것이 web-31(04 MVC2)입니다. 참고로 DTO는 전달용 자료 구조, DAO는 DB 접근 담당이고, Model이 DTO 하나라는 뜻은 아닙니다.
TEXT02_hkboard_MVC1 — 파일별 역할 지도
[Controller] boardController.jsp command 로 분기 → DAO 호출 → scope 저장 → 이동
[View] boardlist.jsp request 의 list 를 표로 그린다
boardDetail.jsp request 의 dto 를 수정 폼으로 그린다
boardInsertForm.jsp 입력 폼만 (자바 코드 없음)
error.jsp 실패 안내
[Model] HkDao SQL 실행 (getAllList, insertBoard, getBoard ...)
HkDto 한 행의 값을 담아 나르는 상자
DataBase 드라이버 로딩 + Connection (HkDao 의 부모)
요청 흐름: 브라우저 → boardController.jsp?command=boardlist
→ HkDao.getAllList() → DB → List<HkDto>
→ request.setAttribute("list", list) → forward → boardlist.jsp → HTML
//1단계: command값 받기 -> 어떤 요청인지 확인하기 위한 값을 받는다
String command = request.getParameter("command");
if (command == null) command = ""; // ← 직접 열었을 때 NPE 방지
//2단계: DAO 객체 생성
HkDao dao = new HkDao();
//3단계: 요청분기(요청확인하기)
if(command.equalsIgnoreCase("boardlist")){ // ← 기능 이름으로 분기
//4단계: 파라미터 받기 생략
//5단계: dao메서드 실행
List<HkDto>list=dao.getAllList(); // ← Model 에게 일을 시키고
//6단계:Scope객체에 담기
request.setAttribute("list", list); // ← 결과를 request 에 담아
//7단계: 페이지 이동
pageContext.forward("boardlist.jsp"); // ← View 에게 넘긴다
}else if(command.equalsIgnoreCase("boardinsertform")){
...
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
작성일 표시 형식만 바꾸려면 어느 파일부터 볼까?
View인 boardlist.jsp부터 봅니다. 화면에 어떻게 보이느냐는 View의 책임이니까요. 반대로 조회 조건·정렬이 바뀌면 DAO(getAllList의 SQL), 어떤 요청에 어떤 화면을 보일지가 바뀌면 Controller를 엽니다. 이렇게 바뀌는 이유에 따라 찾아갈 파일이 정해지는 것이 역할 분리의 효과입니다.
02에는 컨트롤러가 있으니 이미 MVC2인가?
아닙니다. 컨트롤러 역할을 하는 파일이 JSP이므로 요청을 JSP가 받는 MVC1입니다. 또 boardlist.jsp 같은 View에도 getAttribute·for 같은 자바 코드가 남아 있죠. 서블릿이 요청을 받고 JSP는 화면만 그리게 바꿔야 MVC2입니다.
💼 실무·코딩테스트에서는스프링 MVC의 @Controller도 결국 "파라미터 받기 → 서비스·DAO 호출 → Model에 담기 → 뷰 이름 반환"이라는 이 7단계를 그대로 합니다(web-38). 면접에서 "MVC1과 MVC2의 차이"는 "흐름 제어를 JSP가 하느냐 서블릿이 하느냐"로 답하고, 실무에서 더 중요한 건 "바뀌는 이유가 다른 코드를 나눠 둔다"는 목적입니다.
21
네 가지 scope — 누구와 언제까지 공유할까?
scoperequestsessionapplicationsetAttribute
한 줄 요약서버에서 객체를 담아 두는 곳은 page · request · session · application 네 가지이고, 뒤로 갈수록 공유 범위와 수명이 넓어진다. 값마다 필요한 가장 좁은 범위를 고르는 것이 원칙이다.
쉽게 말하면메모를 어디에 붙이느냐의 문제입니다. 지금 보고 있는 화면에만 붙인 포스트잇(page), 이번 주문서 한 장에 끼운 메모(request), 손님 개인 사물함(session), 가게 전체 공용 게시판(application). 넓은 곳에 붙일수록 오래 남고 여러 사람이 본다는 걸 기억하면 어디에 둘지 바로 판단됩니다.
① page — pageContext
현재 JSP 한 장을 처리하는 동안만 유효합니다. 다른 JSP로 forward하면 page 속성은 따라가지 않습니다(다른 JSP의 page는 별개). pageContext.setAttribute("label", "목록")은 기본이 page scope입니다.
② request — 요청 한 번
기준은 "페이지 한 개"가 아니라 "요청 한 번"입니다. forward·include된 대상과 같은 request를 공유하므로, 컨트롤러가 담은 list를 View JSP가 꺼낼 수 있어요. 응답이 끝나면 사라지고, redirect는 새 요청이라 이어지지 않습니다(web-23).
③ session — 같은 사용자의 여러 요청
같은 웹 앱에서 같은 세션 쿠키(JSESSIONID)를 들고 오는 요청들이 공유합니다. 서버 쪽 세션은 유휴 시간 만료나 session.invalidate()로 끝나요. 브라우저를 닫는다고 서버에 즉시 통보되는 것은 아니며, 브라우저 복원 설정에 따라 쿠키가 복구되기도 합니다. 같은 브라우저의 탭들은 보통 쿠키를 공유하므로 탭마다 독립 세션이라고 가정하면 안 됩니다.
④ application — 웹 앱 전체 (ServletContext)
같은 웹 앱의 모든 사용자·모든 요청이 공유합니다. 인터넷 전체에 하나가 아니라 웹 앱별(일반적으로 JVM별)이고, 앱 종료·재배포 때 사라질 수 있어 영구 저장소가 아닙니다. 사용자 개인정보를 두면 다른 사용자에게도 보입니다.
JAVA02_hkboard_MVC1/boardController.jsp — request scope로 화면에 넘기기
List<HkDto>list=dao.getAllList();
//6단계:Scope객체에 담기
request.setAttribute("list", list); // ← 이번 요청에만 필요한 값 → request
//7단계: 페이지 이동
pageContext.forward("boardlist.jsp"); // ← 같은 request 가 그대로 넘어간다
// boardlist.jsp
List<HkDto> list =(List<HkDto>)request.getAttribute("list"); // ← 꺼낼 땐 Object → 형변환
public void init(ServletConfig config) throws ServletException {
...
//ServletContext객체 얻어오기(JSP: application객체)
ServletContext application=config.getServletContext();
application.setAttribute("id", "hk");//객체 저장 // ← 앱 전체가 공유
System.out.println((String)application.getAttribute("id"));
...
}
protected void doGet(HttpServletRequest request, HttpServletResponse respose) ... {
...
//session 객체 얻어오기
HttpSession session = request.getSession(); // ← 없으면 새로 만든다
session.setAttribute("id", "hk"); // ← 이 사용자만의 칸
// --> 요청한 pc에 브라우저에 쿠키를 확인해보면 session_id
}
21번 정리 — 핵심 정리
범위: page < request < session < application. 넓을수록 오래 살고 많이 공유된다.
네 곳 모두 setAttribute / getAttribute / removeAttribute로 같은 방식으로 쓴다. 꺼낸 값은 Object라 형변환이 필요하다.
View로 넘길 조회 결과는 request + forward가 기본이다.
로그인 상태처럼 여러 요청에 걸친 사용자 정보는 session, 앱 공통 설정은 application.
세션은 세션 쿠키(브라우저)와 세션 객체(서버)가 짝이다. 서버 쪽은 만료나 invalidate()로 끝난다.
같은 이름 "id"라도 application의 id와 session의 id는 서로 다른 칸이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
목록을 넘기려고 request 대신 session에 넣으면 어떤 불편함이 생길까?
요청이 끝나도 목록이 남습니다. 다른 화면을 갔다 와도 예전 목록이 그대로 보이는 오래된 데이터 문제가 생길 수 있고, 사용자 수만큼 목록이 서버 메모리에 쌓입니다. 한 번 보여 주고 끝날 값은 request가 맞습니다.
forward하면 page 속성과 request 속성이 모두 유지될까?
request 속성만 이어집니다. forward는 같은 요청을 넘기는 것이라 request는 공유되지만, page scope는 JSP마다 따로라서 받는 JSP의 pageContext에는 보낸 쪽 page 속성이 없습니다.
로그인한 사용자 아이디를 application에 넣으면 무슨 일이 생길까?
모든 사용자가 같은 칸을 봅니다. 마지막에 로그인한 사람의 아이디로 덮여서 다른 사람이 그 사람으로 보이는 심각한 버그가 됩니다. 사용자별 값은 반드시 session에 둡니다.
💼 실무·코딩테스트에서는스프링에서도 Model에 담는 값은 request scope, 로그인 정보는 session(또는 토큰)이라는 구분이 그대로 쓰입니다. 서버를 여러 대 두면 session이 서버마다 따로라서 세션 공유(Redis 등)나 토큰 방식을 고민하게 되는데, 이것도 "세션은 서버 메모리에 있다"는 오늘 내용에서 출발합니다. EL ${id}는 page → request → session → application 순서로 찾는다는 점도 자주 묻습니다(web-32).
22
URL 파라미터와 request 속성 — List 객체 전달
getParametergetAttribute쿼리스트링forward
한 줄 요약파라미터는 브라우저가 보내 온 문자열이고(getParameter), 속성은 서버 코드가 이름을 붙여 보관한 자바 객체다(setAttribute/getAttribute). List 같은 객체를 다음 JSP로 넘기려면 URL이 아니라 request 속성 + forward를 쓴다.
쉽게 말하면파라미터는 손님이 들고 온 쪽지(글자), 속성은 주방이 이름표를 붙여 보관한 실제 그릇입니다. 쪽지에 "그릇"이라고 적어 보낸다고 그릇이 건너가지는 않죠. boardlist.jsp?list=<%= list %>처럼 쓰면 list의 toString() 결과 글자만 주소에 붙을 뿐, 받는 쪽에서 원래 List 객체로 돌아오지 않습니다.
두 저장 공간은 완전히 별개입니다. setAttribute("list", list)를 해도 getParameter("list")로는 읽히지 않고, 반대도 마찬가지예요. 파라미터는 URL 쿼리스트링(?command=boardDetail&seq=3)이나 폼의 name 있는 입력에서 오고, 언제나 String(없으면 null)이라 숫자로 쓰려면 Integer.parseInt가 필요합니다(web-10). 객체를 JSON 같은 약속된 형식으로 바꿔 보내는 방법도 있지만 getParameter가 자동으로 해 주지는 않습니다.
JAVA02_hkboard_MVC1/boardController.jsp — 파라미터로 받고, 속성으로 넘긴다
}else if(command.equalsIgnoreCase("boardDetail")){
//seq파라미터 받기
String pesq=request.getParameter("seq"); // ← 파라미터: 항상 String
int seq = Integer.parseInt(pesq); //String -> int형변환
HkDto dto = dao.getBoard(seq);
//dto객체를 저장하고 이동해야 전달됨
request.setAttribute("dto",dto); // ← 속성: 객체를 그대로 보관
pageContext.forward("boardDetail.jsp"); // ← 같은 request 를 넘긴다
}
JAVA02_hkboard_MVC1/boardlist.jsp · boardDetail.jsp — 받는 쪽
// boardlist.jsp
//boardController.jsp에서 전달된 scope객체로부터 list객체를 가져온다.
List<HkDto> list =(List<HkDto>)request.getAttribute("list");//저장된 객체의 타입은 Object임
// boardDetail.jsp
HkDto dto = (HkDto) request.getAttribute("dto");
// 잘못된 접근(dto가 없는 경우) 시 안전하게 목록으로 튕겨내기
if (dto == null) { // ← 컨트롤러를 안 거치면 null
... alert 후 boardController.jsp?command=boardlist 로 이동
return;
}
실행 결과컨트롤러를 건너뛰고 boardlist.jsp 주소를 직접 열면 먼저 예측 → 펼쳐서 확인
[요청] boardlist.jsp (command 없이, 컨트롤러를 거치지 않음)
request.getAttribute("list") → null (아무도 담지 않았다)
for(HkDto dto : list) → java.lang.NullPointerException
→ HTTP 500
[요청] boardController.jsp?command=boardlist
→ 컨트롤러가 list 를 담고 forward → 목록이 정상 출력
속성은 "누군가 이번 요청에 담아 줬을 때만" 있습니다.boardlist.jsp를 직접 열면 담아 줄 컨트롤러가 실행되지 않았으니 null이고, 확장 for 문이 null 리스트를 돌려다 터집니다. boardDetail.jsp는 if (dto == null)로 이 경우를 막아 두었고, boardlist.jsp는 아직 막혀 있지 않습니다. 그래서 목록 진입 주소는 항상 boardController.jsp?command=boardlist입니다.
22번 정리 — 핵심 정리
파라미터 = 브라우저가 보낸 값, String, 읽기 전용 → getParameter.
속성 = 서버가 담은 값, Object, 형변환 필요 → setAttribute/getAttribute.
없는 파라미터는 null → parseInt(null)·equals 호출 전에 확인(컨트롤러의 if (command == null) command = "";).
View JSP를 직접 열면 속성이 null — 항상 컨트롤러를 거치게 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
setAttribute("list", list) 다음에 getParameter("list")로 읽을 수 있을까?
아니요, null이 나옵니다.getParameter는 브라우저가 보낸 값만 보는 API이고, 서버가 담은 속성은 getAttribute로만 꺼낼 수 있습니다. 이름이 같아도 저장 공간이 다릅니다.
list를 담은 뒤 forward 대신 redirect하면 왜 list가 없어질까?
redirect는 브라우저가 새 요청을 보내게 하기 때문입니다. 속성은 이전 요청 객체에 담겨 있었고, 새 요청에는 자동으로 복사되지 않습니다. forward는 서버 안에서 같은 request를 넘기므로 속성이 살아 있습니다.
💼 실무·코딩테스트에서는스프링에서는 파라미터를 @RequestParam·DTO 바인딩으로, 속성을 Model.addAttribute로 다루지만 "요청에서 온 문자열"과 "서버가 화면에 넘기는 객체"라는 구분은 똑같습니다. 화면 사이에 객체를 넘겨야 할 때 URL로는 식별자(seq)만 보내고, 받는 쪽 컨트롤러가 DB에서 다시 조회하는 것이 일반적인 설계예요. 면접에서 getParameter와 getAttribute의 차이는 단골 질문입니다.
23
화면 이동 — HTML · JavaScript · JSP/Servlet
forwardsendRedirectlocation.hrefonclick
한 줄 요약화면을 옮기는 방법은 여러 가지지만, 먼저 "브라우저가 새 요청을 만드는가(링크·form·location.href·redirect)", 아니면 "서버 안에서 같은 요청을 넘기는가(forward)"를 판단한다. request 속성이 따라가는 것은 forward뿐이다.
쉽게 말하면redirect는 창구 직원이 "저쪽 창구로 다시 줄 서세요"라고 안내하는 것이고, forward는 직원이 서류째 안쪽 창구로 넘겨주는 것입니다. 손님(브라우저)은 forward가 일어난 줄 모르니 주소창도 그대로예요. 서류(request 속성)는 forward 때만 따라갑니다.
① 브라우저가 새 요청을 만드는 방법
HTML 링크·form 제출, JavaScript location.href, 그리고 서버가 "여기로 다시 요청하라"고 응답하는 response.sendRedirect. 셋 다 새 요청이라 주소창이 바뀌고, 이전 요청의 속성은 전달되지 않습니다. 값을 넘기려면 ?seq=3처럼 파라미터로 붙여야 해요.
② 서버 안에서 넘기는 방법
pageContext.forward(JSP) · RequestDispatcher.forward(서블릿). 같은 요청을 다른 JSP에 넘기므로 주소창은 원래 주소 그대로, request 속성도 그대로입니다. 그래서 DAO로 조회한 결과를 화면에 보여 줄 때 씁니다.
③ 이동 코드를 쓸 때 주의
forward·redirect는 응답이 이미 전송된(committed) 뒤 호출하면 오류가 날 수 있어 출력보다 먼저 둡니다. sendRedirect는 아래 코드를 멈추지 않으므로 이후 처리가 남아 있다면 return;으로 끝내는 것이 명확합니다. 등록·수정 성공 뒤 redirect로 목록을 다시 요청하면 새로고침으로 POST가 다시 전송되는 일을 줄일 수 있습니다(PRG 패턴).
JS02_hkboard_MVC1/boardlist.jsp — 함수 정의와 버튼 연결
// 글쓰기 폼 요청: controller를 통해 처리
function boardInsertForm(){
location.href="boardController.jsp?command=boardInsertForm"; // ← 브라우저가 새 요청
}
...
<button type="button" onclick="boardInsertForm()">글추가</button> // ← 이 연결이 있어야 실행된다
JAVA02_hkboard_MVC1/boardController.jsp — forward와 redirect가 갈리는 자리
if(command.equalsIgnoreCase("boardlist")){
List<HkDto>list=dao.getAllList();
request.setAttribute("list", list);
pageContext.forward("boardlist.jsp"); // ← 조회 결과를 들고 서버 안에서 이동
}else if(command.equalsIgnoreCase("boardinsertform")){ // ← JS 는 "boardInsertForm"
//글쓰기 폼으로 이동 요청 // 대소문자 무시라 일치한다
response.sendRedirect("boardInsertForm.jsp"); // ← 넘길 데이터가 없으니 새 요청으로
}
실행 결과각 동작 뒤 주소창에 남는 주소 먼저 예측 → 펼쳐서 확인
[목록 보기] index.jsp 의 "게시판목록" 클릭
요청: boardController.jsp?command=boardlist
서버: forward → boardlist.jsp
주소창: boardController.jsp?command=boardlist ← forward 는 주소가 안 바뀐다
[글추가] 목록의 "글추가" 버튼 클릭
요청 1: boardController.jsp?command=boardInsertForm (location.href)
응답 1: 302 → boardInsertForm.jsp (sendRedirect)
요청 2: boardInsertForm.jsp
주소창: boardInsertForm.jsp ← redirect 는 최종 주소로 바뀐다
주소창만 봐도 forward인지 redirect인지 구별할 수 있습니다. 목록 화면은 실제로는 boardlist.jsp가 그렸지만 주소창에는 컨트롤러 주소가 남아 있죠. 그래서 목록에서 새로고침을 누르면 컨트롤러가 다시 실행되어 최신 목록을 조회합니다.
글추가는 요청이 두 번 오갑니다. 브라우저 개발자 도구의 Network 탭을 열고 눌러 보면 302 응답 다음에 boardInsertForm.jsp 요청이 이어지는 것을 직접 볼 수 있습니다.
23번 정리 — 핵심 정리
링크·form·location.href·sendRedirect = 새 요청 → 주소 변경, request 속성 사라짐.
forward = 서버 내부 이동 → 주소 유지, request 속성 유지.
조회 결과를 보여 줄 때는 forward, 등록·수정·삭제 뒤 목록으로 갈 때는 redirect(web-25).
JS 함수는 정의만으로는 실행되지 않는다 — onclick이나 이벤트 리스너로 연결해야 한다.
sendRedirect는 이후 코드를 멈추지 않는다 — 필요하면 return;.
이동 코드는 응답 출력보다 먼저 — 이미 전송된 뒤에는 이동할 수 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DAO 조회 결과를 보여 줄 때 forward가 잘 맞는 이유는?
이미 얻은 객체를 request에 담아 같은 요청으로 View에 넘길 수 있기 때문입니다. redirect를 쓰면 새 요청이라 담아 둔 list가 사라지고, 받는 JSP가 다시 조회해야 합니다.
글 등록이 끝난 뒤 forward로 목록을 보여 주면 무엇이 문제일까?
주소창에 등록 요청(POST)이 그대로 남습니다. 그 상태에서 새로고침하면 브라우저가 같은 등록 요청을 다시 보내 글이 두 번 들어갈 수 있어요. 그래서 등록 뒤에는 redirect로 목록 요청을 새로 만들게 합니다.
function boardInsertForm()을 선언만 하면 버튼 클릭 때 실행될까?
아니요. 함수는 호출될 때만 실행됩니다. onclick="boardInsertForm()"처럼 버튼과 연결해야 클릭 때 새 요청이 나갑니다.
💼 실무·코딩테스트에서는"forward와 redirect의 차이"는 서블릿·스프링 면접의 단골 질문입니다. 답의 핵심은 요청 횟수(1번/2번), 주소창 변화, request 속성 유지 여부 세 가지예요. 스프링에서는 뷰 이름을 돌려주면 forward, "redirect:/board/list"를 돌려주면 redirect가 되고, POST 처리 후 redirect(PRG 패턴)는 실무 표준입니다.
24
Web Server와 WAS — 역할과 요청 흐름
Web ServerWASTomcat서블릿 컨테이너정적 · 동적
한 줄 요약Web Server는 이미 만들어진 정적 파일(HTML·CSS·이미지)을 돌려주고, WAS는 요청마다 서버 코드(서블릿·JSP)를 실행해 응답을 만들어 돌려준다. 다만 둘은 역할 구분이고, Tomcat처럼 한 제품이 두 역할을 함께 하기도 한다.
쉽게 말하면Web Server는 진열대의 완제품을 건네는 점원, WAS는 주문을 받아 즉석에서 조리하는 주방입니다. "게시판 목록 주세요"라는 주문은 DB에서 최신 글을 꺼내 그때그때 만들어야 하니 주방(WAS)의 일이고, 로고 이미지는 진열대에서 바로 집어 주면 됩니다. Tomcat은 작은 가게처럼 둘 다 하는 곳이에요.
Tomcat은 서블릿 컨테이너입니다 — 서블릿·JSP를 실행하고, 그 자체로 HTTP 요청을 받고 정적 파일도 줄 수 있어요. 수업에서는 이것을 WAS 역할로 씁니다. 다만 Jakarta EE의 모든 기능을 구현한 제품이라고 외우면 안 됩니다(Servlet·Pages·EL 등 웹 부분 명세를 구현). 운영 환경에서는 Nginx·Apache 같은 별도 웹 서버를 앞에 두고 정적 파일·HTTPS·부하 분산을 맡기기도 하지만, 학습용 앱은 Tomcat 하나로 충분히 실행됩니다.
TEXT수업 앱과 이 학습 노트 사이트의 요청 흐름
수업 앱 (02_hkboard_MVC1)
브라우저 → Tomcat → boardController.jsp → HkDao → MariaDB
← HTML 응답 ← boardlist.jsp (서버에서 실행되어 만들어진 HTML)
학습 노트 (GitHub Pages)
브라우저 → GitHub Pages → 이미 만들어진 HTML/CSS/JavaScript 파일
(GitHub Pages 에서는 JSP·JDBC 같은 서버 코드가 실행되지 않음)
JAVA03_hello_servlet/HelloServlet.java — WAS가 응답을 "만드는" 장면
//파라미터 받기
String param = request.getParameter("param");
//서블릿에서 클라이언트로 응답하기
PrintWriter out=respose.getWriter();
out.print("<h1 style='color:blue;'>서블릿개념</h1>");
out.print("<h2>서블릿 기본 내용 알아보기</h2>");
out.print("<h2>서블릿에서 받은 파라미터:"+param+"</h2>"); // ← 요청마다 내용이 달라진다
out.print("<h3><a href='index.jsp'>메인으로 돌아가기</a></h3>");
실행 결과index.jsp의 "요청하기"(HelloServlet.do?param=servlet)를 누르면 먼저 예측 → 펼쳐서 확인
서블릿개념 (파란색 큰 제목)
서블릿 기본 내용 알아보기
서블릿에서 받은 파라미터:servlet
메인으로 돌아가기 (링크)
이 화면은 디스크 어디에도 HTML 파일로 저장되어 있지 않습니다. 요청이 올 때 서블릿이 out.print로 그 자리에서 글자를 만들어 보낸 것이에요. 주소를 HelloServlet.do?param=hello로 바꿔 보면 셋째 줄이 "파라미터:hello"로 바뀝니다 — 이것이 정적 파일과 동적 응답의 차이입니다.
24번 정리 — 핵심 정리
정적 응답 = 이미 있는 파일 그대로, 동적 응답 = 요청마다 코드를 실행해 만든 결과.
Web Server는 정적 파일·HTTP 처리·역방향 프록시, WAS는 서버 코드 실행·업무 처리·DB 연동.
Tomcat = 서블릿 컨테이너. HTTP도 직접 받으므로 앞에 웹 서버가 없어도 JSP가 실행된다.
Tomcat 10.1은 jakarta.servlet 패키지를 쓴다(예전 javax.servlet 아님). → 🖥️ 01. 개발 환경
GitHub Pages 같은 정적 호스팅에서는 JSP·DB가 실행되지 않는다.
문제가 나면 404 = 주소·배포 경로, 500 = 서버 코드(로그 확인), DB 오류 = 접속 설정·DB 상태로 구간을 좁힌다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
Tomcat 앞에 별도 웹 서버가 없으면 JSP를 실행할 수 없을까?
실행할 수 있습니다. Tomcat이 직접 HTTP 요청을 받는 커넥터를 갖고 있어서, 수업처럼 localhost:8080으로 바로 접속해도 됩니다. 앞단 웹 서버는 성능·보안·운영 편의를 위한 선택입니다.
GitHub Pages에 JSP를 올리면 게시판 DB까지 실행될까?
아니요. GitHub Pages는 정적 파일을 그대로 돌려주는 서비스라 JSP를 번역·실행할 서블릿 컨테이너가 없습니다. JSP 파일은 실행되지 않은 채 다뤄지고 DB 연결도 없죠. 실제 게시판은 로컬 Tomcat과 MariaDB에서 실행합니다.
💼 실무·코딩테스트에서는운영 환경에서는 흔히 Nginx(웹 서버) → Tomcat(WAS) → DB 구조를 쓰고, 스프링 부트는 Tomcat을 내장해 jar 하나로 실행합니다. 장애가 나면 "어느 구간에서 막혔나"를 먼저 가려야 하니 이 흐름을 그림으로 그릴 수 있어야 해요. 면접의 "웹 서버와 WAS의 차이"는 "정적 vs 동적, 그리고 한 제품이 둘 다 할 수 있다"까지 말하면 좋습니다. 참고: Tomcat 10.1 공식 문서.
25
게시판 CRUD 연결 — 등록·상세·수정·단일 삭제
CRUDcommand · seqPreparedStatement빈 DTO
한 줄 요약컨트롤러는 command로 기능을 고르고, seq로 대상 글을 고른 뒤, DAO 결과에 따라 조회는 forward, 변경은 redirect로 이어 간다. 등록·상세·수정·삭제 네 기능이 모두 같은 틀로 연결된다.
쉽게 말하면command는 접수표의 "무슨 업무" 칸, seq는 "몇 번 서류" 칸입니다. 찾은 서류를 바로 보여 줄 땐 그 자리에서 건네고(forward), 서류를 고치거나 버린 뒤엔 "목록 창구로 다시 오세요"(redirect)라고 안내해 최신 목록을 새로 받게 합니다.
컨트롤러(boardController.jsp)는 boardInsert·boardDetail·boardUpdate·boardDelete command로 분기하고, HkDao의 insertBoard·getBoard·updateBoard·deleteBoard를 부릅니다. 변경 메서드는 executeUpdate()가 돌려준 영향 행 수가 0보다 크면 true를 돌려주고, 컨트롤러는 true면 목록(또는 상세)으로, false면 error.jsp로 보냅니다. 수정과 단일 삭제는 상세 화면에서 요청합니다 — 수정은 form의 hidden seq로, 삭제는 location.href='...command=boardDelete&seq='+seq로요.
JAVA02_hkboard_MVC1/boardController.jsp — 등록 성공 후 목록 "컨트롤러"로 이동
}else if(command.equalsIgnoreCase("boardInsert")){
//파라미터 받기 : id, title, content
String id = request.getParameter("id");
String title = request.getParameter("title");
String content = request.getParameter("content");
boolean isS=dao.insertBoard(new HkDto(id,title,content)); // ← 글추가용 생성자
if(isS){
//그냥 boardlist.jsp페이지로 가면 안되고,
//반드시 컨트롤러를 거쳐서 가야 한다 --> list객체가 필요하기 때문
response.sendRedirect("boardController.jsp?command=boardlist"); // ← JSP 가 아니라 컨트롤러로
}else{
response.sendRedirect("error.jsp");
}
JAVA02_hkboard_MVC1/HkDao.java — 수정 SQL과 한 건 조회
public boolean updateBoard(HkDto dto) {
String sql = " UPDATE HKBOARD SET TITLE=?,CONTENT=?"
+ " WHERE SEQ=? ";
...
psmt.setString(1, dto.getTitle());
psmt.setString(2, dto.getContent());
psmt.setInt(3, dto.getSeq()); // ← WHERE 의 ? 가 3번
count = psmt.executeUpdate(); //실행: 반환값은 수정된 행의 개수
...
return count>0?true:false;
}
public HkDto getBoard(int seq){
HkDto dto = new HkDto(); // ← 처음부터 빈 상자를 만든다
...
while(rs.next()) { // ← 행이 있을 때만 값이 채워진다
dto.setSeq(rs.getInt(1));
...
}
return dto; // ← 못 찾아도 null 이 아니다
}
실행 결과없는 글 번호로 상세를 요청하면 (boardController.jsp?command=boardDetail&seq=999) 먼저 예측 → 펼쳐서 확인
getBoard(999) → null 이 아니라 빈 HkDto (seq=0, id/title/content=null)
boardDetail.jsp 의 if (dto == null) → 거짓 → 그대로 화면을 그린다
작성자(ID) null
글제목 [null ]
글내용 [null ]
(hidden seq = 0)
이 상태에서 "글수정"을 누르면
UPDATE ... WHERE SEQ=0 → 0행 → false → error.jsp
seq=abc 또는 seq 없이 요청하면
Integer.parseInt → NumberFormatException → HTTP 500
없는 글을 조회하면 null이 아니라 "빈 DTO"가 옵니다.getBoard(seq)는 메서드 첫 줄에서 HkDto dto = new HkDto();를 만들어 두고, 조회된 행이 있을 때만 값을 채운 뒤 그대로 돌려줍니다. 그래서 결과는 모든 필드가 비어 있는 객체(seq=0, title=null)이고, 상세 JSP의 if (dto == null) 검사로는 "글이 없음"을 잡지 못합니다. 화면의 "null" 글자는 <%=dto.getTitle()%>가 null을 문자열 "null"로 찍은 것이에요.
고치는 방법은 둘 중 하나입니다. DAO가 없을 때 null을 돌려주게 바꾸거나(web-12의 getUser처럼 if (rs.next()) 안에서만 객체 생성), dto.getSeq() == 0처럼 내용으로 검사합니다.
25번 정리 — 핵심 정리
command = 무슨 기능, seq = 어느 글. 컨트롤러 분기 하나가 DAO 메서드 하나와 짝을 이룬다.
상세 조회는 forward(dto를 request에 담아), 등록·수정·삭제 뒤에는 redirect.
등록 뒤에는 boardlist.jsp가 아니라 boardController.jsp?command=boardlist로 — 목록을 다시 조회해야 하므로.
SQL의 ? 번호는 문자열에 나타나는 순서 — WHERE SEQ=?가 3번.
변경 성공 판정은 executeUpdate()의 영향 행 수 > 0.
getBoard는 못 찾아도 빈 DTO를 돌려준다 — dto == null 검사로는 "글 없음"을 못 잡는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
등록 후 boardlist.jsp로 바로 redirect하면 왜 목록이 안 나올까?
새 요청에는 list 속성이 없고, DAO 조회도 실행되지 않았기 때문입니다. boardlist.jsp는 컨트롤러가 담아 준 list를 꺼내 그리기만 하므로, 목록 컨트롤러를 거쳐 다시 조회해야 합니다(직접 열면 null 리스트로 500 — web-22).
수정 SQL에서 setString(1, title)과 setInt(3, seq)의 순서를 바꿔 쓰면?
SQL의 ? 위치와 다른 값이 들어갑니다. 자바 컴파일러는 이 실수를 잡아 주지 못하고, 실행해도 에러 없이 엉뚱한 칸에 값이 들어가거나 대상 조건이 틀어질 수 있어 찾기 어렵습니다. 기준은 SQL 문자열 속 ?의 순서이고, 여기서 WHERE SEQ=?는 세 번째입니다.
💼 실무·코딩테스트에서는hidden seq도 사용자가 바꿀 수 있는 입력입니다. 실제 서비스에서는 대상 글 존재 확인·작성자 권한 검사·서버 입력 검증이 필요해요. 또 수업 코드의 GET 삭제(location.href로 삭제 요청)는 요청 흐름을 배우기 위한 예이고, 운영 서비스에서는 상태를 바꾸는 요청을 POST(또는 DELETE)로 보내는 것이 원칙입니다. "없으면 null이냐 빈 객체냐 예외냐"는 실무에서도 팀마다 정해 두는 중요한 설계 약속입니다.
정리 작업 메모
코드 검토에서 확인한 남은 부분: 원본의 수정 성공 분기는 script 태그와 out.println 예시가 함께 있어 알림·이동 코드가 중복된다. seq의 숫자 검사와 POST 요청 인코딩 설정도 별도 확인이 필요하다.
error.jsp는 9월 17일 코드에 추가되었다. 이 기록은 처리 코드가 추가되었다는 뜻이며 모든 실패 경로의 정상 동작을 확인했다는 뜻은 아니다.
26
form의 name과 hidden · 체크박스 전체 선택
form namehidden체크박스getElementsByNameonsubmit
한 줄 요약화면에 입력칸이 있다고 서버로 가는 것은 아니다. name이 있어야 전송되고, 서버는 그 이름으로 getParameter한다. 목록의 체크박스들은 모두 같은 name(seq)이라 여러 값이 한 이름으로 함께 간다.
쉽게 말하면택배는 받는 사람 이름(name)이 적혀 있어야 배달됩니다. hidden은 상자 속에 넣은 메모일 뿐 잠긴 금고가 아니에요 — 화면에 안 보일 뿐 누구나 열어 고칠 수 있습니다. 그리고 전체 선택은 체크 표시만 바꾸는 화면 동작이지, 그 자체로 아무것도 지우지 않습니다.
name은 서버에서 getParameter("title")로 찾는 이름이고, id는 화면(JS·CSS)에서 요소를 찾는 이름이라 id만 있으면 전송되지 않습니다. hidden 입력은 사용자가 입력하지 않지만 함께 보내야 하는 값 — 여기서는 컨트롤러 분기용 command와 수정할 글 번호 seq — 을 실어 나릅니다. textarea의 초기값은 value 속성이 아니라 시작·끝 태그 사이에 쓰고(그래서 상세 화면은 태그 사이에 공백 없이 붙여 씀), required는 브라우저 검증이라 서버 검증을 대신하지 않습니다.
HTML02_hkboard_MVC1/boardInsertForm.jsp — name이 곧 파라미터 이름
//전체 선택 체크박스 기능
function allSel(bool){
// 체크박스 요소들을 구한다(배열형태)
const chkObj=document.getElementsByName("seq");//[chk,chk...] // ← 같은 name 을 전부
for(let i=0;i<chkObj.length;i++){
chkObj[i].checked=bool; // ← 화면의 체크 상태만 바꾼다
}
}
//삭제 체크박스 유효값 처리
function isAllCheck(){
const chks=document.querySelectorAll("input[name=seq]:checked");
if(chks.length==0){//체크 개수가 0
document.querySelector("#msg").textContent="하나이상 체크하세요";
return false;// submit이벤트를 취소하기 위해 false 반환 // ← 전송 취소
}else{
if(confirm("정말 삭제하겠습니까?")){
return true;//submit O
} ...
}
<form action="boardController.jsp" method="post" onsubmit="return isAllCheck()">
<input type="hidden" name="command" value="muldel"/>
<th><input type="checkbox" name="all" onclick="allSel(this.checked)"/></th> <!-- ← true/false 전달 -->
...
<td><input type="checkbox" name="seq" value="<%=dto.getSeq()%>" /></td> <!-- ← 행마다 같은 name -->
실행 결과3번·7번 글만 체크하고 "글삭제"를 누르면 서버로 가는 값 먼저 예측 → 펼쳐서 확인
command=muldel
seq=7
seq=3 (form 안에서 체크박스가 놓인 순서대로 — 목록이 최신 글부터라면 7이 먼저)
* 체크하지 않은 체크박스는 아예 전송되지 않는다.
* 같은 이름 seq 가 두 번 → 서버에서 getParameterValues("seq") 로 문자열 배열 두 칸
* getParameter("seq") 로 읽으면 그중 첫 번째 값 하나만 나온다.
* 아무것도 체크하지 않으면 isAllCheck() 가 false 를 돌려 요청 자체가 나가지 않는다.
onsubmit="return isAllCheck()"의 return이 중요합니다. 함수가 false를 돌려줘도 앞에 return이 없으면 제출 취소로 이어지지 않아요. 그리고 이 검사는 브라우저에서만 일어나므로, 개발자 도구나 다른 도구로 command=muldel만 보내면 서버는 seq 없는 요청을 받게 됩니다 — 그 결과는 web-27에서 다룹니다.
체크된 체크박스만 전송되고, 같은 name 여러 값은 getParameterValues로 배열로 받는다.
allSel(this.checked)는 화면의 checked 상태만 바꾼다 — 삭제는 form 제출 → 컨트롤러 → DAO가 한다.
onsubmit="return 함수()"에서 false를 돌려주면 제출이 취소된다.
상세 화면의 한 글 삭제(boardDelete)와 목록의 여러 글 삭제(muldel)는 다른 분기다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
textarea에 id="content"만 있고 name이 없으면 서버에서 content를 읽을 수 있을까?
읽을 수 없습니다(null). form 제출은 name이 있는 입력만 "이름=값"으로 묶어 보냅니다. id는 JS·CSS가 요소를 찾는 데 쓰는 이름이라 전송과 관계가 없어요. name="content"가 필요합니다.
전체 선택을 체크했다가 해제하면 DB 데이터가 바뀔까?
아니요.allSel은 브라우저 화면의 checked 값만 바꿉니다. 서버에는 요청이 하나도 가지 않아요. 실제 삭제는 "글삭제" 제출 → 컨트롤러 → mulDel SQL 실행을 거쳐야 일어납니다.
상세 화면의 hidden seq 값을 개발자 도구로 다른 번호로 바꾸고 "글수정"을 누르면?
바꾼 번호의 글이 수정됩니다. 서버는 hidden 값이 원래 값인지 알 방법이 없고, 수업 코드는 작성자 확인도 하지 않기 때문이에요. 그래서 실제 서비스는 서버에서 "이 사용자가 이 글을 고칠 권한이 있나"를 다시 확인합니다.
💼 실무·코딩테스트에서는스프링에서는 form의 name이 DTO의 필드 이름과 같으면 자동으로 채워지는 바인딩을 쓰므로, name을 정확히 맞추는 습관이 그대로 중요합니다. 그리고 "클라이언트 검증은 편의, 서버 검증은 필수"는 보안 기본 원칙이에요 — hidden·required·JS 검사는 모두 사용자가 우회할 수 있습니다. 같은 name 다중 값은 스프링에서 String[]·List<Integer> 파라미터로 받습니다.
한 줄 요약체크한 글 번호들을 getParameterValues("seq")로 배열로 받고, 같은 DELETE 문에 번호만 바꿔 batch로 한 번에 실행한다. 이때 자동 커밋을 끄고(트랜잭션) 모두 괜찮을 때만 commit, 문제가 나면 rollback한다.
쉽게 말하면버릴 서류를 한 묶음(batch)으로 모아 한 번에 처리하고, 전부 괜찮을 때만 확정 도장(commit)을 찍는 것이 트랜잭션입니다. 중간에 문제가 생기면 없던 일(rollback)로 돌려요. 다만 도장을 찍은 뒤에 서류를 검사하면 이미 늦습니다 — 이번 수업 코드에서 꼭 짚어야 할 부분이 바로 이것입니다.
① 흐름 — form → muldel → mulDel → 목록
목록 form이 command=muldel과 체크한 seq들을 보내면(web-26), 컨트롤러가 getParameterValues("seq")로 String[]을 받아 HkDao.mulDel에 넘기고, 성공하면 목록 컨트롤러로 redirect합니다. 체크하지 않은 칸은 전송되지 않습니다.
② 트랜잭션과 autoCommit
JDBC 연결은 기본이 autoCommit=true라 SQL 한 문장마다 바로 확정됩니다. conn.setAutoCommit(false)로 끄면 commit()을 부를 때까지 묶어 두었다가 한꺼번에 확정하고, 문제가 생기면 rollback()으로 모두 취소합니다. 끝나면 finally에서 원래 설정(true)으로 되돌립니다. → 📖 트랜잭션
③ addBatch / executeBatch
addBatch()는 "이번 ? 값으로 한 번 실행"을 바로 보내지 않고 모아 두고, executeBatch()가 모은 것을 한 번에 실행합니다. 반환값은 int[] — 각 실행의 결과(영향 행 수)가 순서대로 들어 있습니다. 같은 SQL에 값만 바뀌는 반복 작업에 쓰는 도구예요.
String sql = "DELETE FROM HKBOARD WHERE SEQ = ?";
try(Connection conn=getConnection();){
// 자동 commit 해제 --> rollback할 수 있음
conn.setAutoCommit(false); // ← ① 트랜잭션 시작
try(PreparedStatement psmt=conn.prepareStatement(sql)){
for (int i = 0; i < seqs.length; i++) {
psmt.setString(1, seqs[i]);//쿼리 하나 완성
psmt.addBatch();//완성된 쿼리를 준비시켜줌 // ← ② 묶음에 추가
}
count = psmt.executeBatch();//batch 실행 후 결과는 배열반환 // ← ③ 한 번에 실행
conn.commit();//DB에 반영 // ← ④ 확정 (검사보다 먼저!)
}catch (SQLException e) {
conn.rollback();// 오류가 나면 성공한 작업 되돌리기
e.printStackTrace();
}finally {
//원래 설정으로 되돌리기
conn.setAutoCommit(true);
}
// count[1,1,1,1,1] 각각의 쿼리가 성공하면 1
if(count!=null) {
for (int i = 0; i < count.length; i++) {
if(count[i]!=1) { // ← ⑤ commit 뒤에 검사한다
isS=false;
break;
}
}
}else { isS=false; }
JAVA권장 순서 — 검사를 commit 앞으로
int[] counts = psmt.executeBatch();
boolean allOk = true;
for (int c : counts) {
if (c != 1) { allOk = false; break; }
}
if (allOk) conn.commit(); // ← 전부 성공일 때만 확정
else conn.rollback(); // ← 하나라도 아니면 전부 취소
실행 결과수업 코드에서 생기는 두 가지 경우 먼저 예측 → 펼쳐서 확인
[경우 1] 다른 탭에서 이미 지운 글이 선택 목록에 섞여 있을 때
executeBatch → 남아 있던 글은 삭제, 이미 없던 글은 삭제할 행이 없음
conn.commit() → 남아 있던 글의 삭제가 확정됨
그 뒤 count 검사 → 1이 아닌 칸 발견 → isS=false → error.jsp
⇒ 화면은 "시스템 오류"인데 DB 에서는 일부 글이 이미 지워져 있다
[경우 2] seq 없이 command=muldel 만 보내졌을 때 (브라우저 검사를 우회)
getParameterValues("seq") → null
mulDel 안의 seqs.length → NullPointerException
(SQLException 만 잡으므로 예외가 그대로 올라감) → HTTP 500
수업 원본은 영향 행 수를 commit 뒤에 검사합니다. 그래서 존재하지 않는 번호가 섞여 있어도 나머지 삭제는 이미 반영됩니다. 업무상 "전부 성공해야 한다"면 입력 검사와 실행 결과 판정을 commit 전에 해야 합니다(위 권장 순서).
브라우저의 미선택 검사(isAllCheck)도 서버 검사를 대신하지 않습니다. 컨트롤러나 DAO 맨 앞에서 if (seqs == null || seqs.length == 0)을 먼저 확인해야 경우 2를 막을 수 있습니다.
27번 정리 — 핵심 정리
같은 name 여러 값은 getParameterValues → String[], 아무것도 없으면 null.
JDBC 기본은 autoCommit=true — 여러 문장을 하나로 묶으려면 setAutoCommit(false).
성공이면 commit(), 예외면 rollback(), 끝나면 autoCommit 복원.
addBatch()로 모으고 executeBatch()로 한 번에 — 결과는 int[].
결과 검사는 commit 전에 — commit 뒤에 검사하면 일부 삭제가 이미 확정된 뒤다.
트랜잭션은 "전부 아니면 전무" — 계좌 이체처럼 일부만 반영되면 안 되는 작업에 필수다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
setAutoCommit(false)를 빼고 같은 코드를 실행하면 무엇이 달라질까?
문장마다 바로 확정되어 rollback이 의미가 없어집니다. 중간에 예외가 나도 그 전에 실행된 DELETE는 이미 반영된 상태라 rollback()으로 되돌릴 수 없어요. 여러 문장을 하나의 단위로 묶는 것이 autoCommit을 끄는 이유입니다.
수업 코드에서 count 검사가 실패해 false를 돌려줄 때, DB는 이미 어떤 상태일까?
이미 commit된 상태입니다. 검사가 conn.commit() 뒤에 있으니, 지울 수 있었던 글은 벌써 지워졌고 되돌릴 수 없어요. 사용자는 error.jsp를 보지만 데이터는 일부 바뀐 셈입니다. 그래서 검사를 commit 앞으로 옮기고 실패 시 rollback해야 합니다.
finally에서 setAutoCommit(true)로 되돌리는 이유는?
연결을 다시 쓰는 쪽이 예상치 못한 상태를 만나지 않게 하려는 것입니다. 지금은 매번 새 연결을 만들고 닫지만, 나중에 커넥션 풀을 쓰면 연결이 반납 후 재사용되므로, autoCommit이 꺼진 채 돌아가면 다음 사용자의 SQL이 확정되지 않는 문제가 생길 수 있습니다.
💼 실무·코딩테스트에서는스프링에서는 이 commit·rollback을 @Transactional이 대신해 줍니다. 단, 프록시 방식이라 같은 클래스 안에서 자기 메서드를 부르면 적용되지 않고, 기본으로는 RuntimeException에서만 롤백한다는 점이 면접 단골입니다. 다중 삭제는 batch 대신 DELETE ... WHERE SEQ IN (...) 한 문장으로 쓰기도 하는데, MyBatis에서는 <foreach>로 만듭니다. 또 JDBC 규약상 executeBatch() 결과에는 행 수 대신 Statement.SUCCESS_NO_INFO(-2)가 올 수도 있어, 실무에서는 사용하는 드라이버가 무엇을 돌려주는지 확인하고 판정 기준을 정합니다.
28
Servlet 요청 처리와 생명주기
@WebServletinit · service · destroydoGet · doPostsuper.init(config)
한 줄 요약서블릿은 URL에 매핑된 자바 클래스다. 컨테이너(Tomcat)가 객체를 한 번 만들어 init하고, 요청마다 service → doGet/doPost로 처리하며, 서버 종료·재배포 때 destroy를 한 번 부른다.
쉽게 말하면서블릿은 한 번 채용되면(init) 계속 일하는 창구 직원입니다. 손님이 올 때마다 새로 뽑는 게 아니라, 같은 직원이 GET 창구·POST 창구로 안내하며 일하고, 가게 문을 닫을 때(destroy) 퇴근해요. 여러 손님이 한 직원을 동시에 쓰기 때문에, 직원 책상(인스턴스 필드)에 손님 개인 서류를 올려 두면 섞입니다.
① URL 매핑 — 어노테이션 또는 web.xml
@WebServlet("/HelloServlet.do")는 http://localhost:8080/03_hello_servlet/HelloServlet.do 요청을 이 클래스에 연결합니다(포트·컨텍스트 경로는 내 설정에 따라). 수업 코드처럼 initParams로 초기값도 줄 수 있고, 이 값은 config.getInitParameter("name")으로 읽습니다. 같은 매핑을 web.xml의 <servlet>·<servlet-mapping>으로 적을 수도 있어요 — 수업 web.xml에 주석으로 남아 있는 부분이 그것입니다.
② 생명주기 — 객체는 하나, 요청 처리는 여러 번
첫 요청 때(또는 loadOnStartup을 설정하면 서버 시작 때) 컨테이너가 객체를 만들고 init(ServletConfig)를 한 번 부릅니다. 이후 요청마다 service(req, resp)가 실행되어 GET이면 doGet, POST면 doPost로 나눠요. destroy()는 서버 종료·앱 재배포 때 한 번입니다. 수업 코드의 destroy 주석("요청이 더이상 없으면 자동으로 소멸")과 달리, 잠깐 요청이 없다고 객체가 사라지지는 않습니다.
③ init(ServletConfig)를 재정의할 때는 super.init(config)
수업의 init(ServletConfig)는 super 호출이 빠져 있습니다. 상위 클래스(GenericServlet)의 init(config)가 config를 저장한 뒤 인자 없는 init()을 호출하기 때문에, 이걸 빠뜨리면 ① 나중에 getServletConfig()가 null이라 getInitParameter()·getServletContext() 같은 편의 메서드가 NullPointerException을 내고 ② 같은 클래스에 만든 인자 없는 init()은 호출되지 않습니다(수업 코드의 "init():최초 한번 실행"이 찍히지 않는다). 초기화 코드는 인자 없는 init()을 재정의하고 getInitParameter()를 쓰는 편이 간단합니다. → Jakarta Servlet 6.0 GenericServlet
//url-mapping 어노테이션 방식: tomcat7.0부터 지원
@WebServlet(
urlPatterns = {"/HelloServlet.do"}, // ← 이 주소로 오면 이 클래스
initParams = {
@WebInitParam(name="name",value="한경닷컴") // ← 이 서블릿만의 초기값
}
)
public class HelloServlet extends HttpServlet{
@Override
public void init() throws ServletException {
System.out.println("init():최초 한번 실행"); // ← 아래 때문에 호출되지 않는다
}
@Override
public void init(ServletConfig config) throws ServletException {
// (super.init(config); 가 빠져 있다) // ← 수업 코드의 빈자리
String name=config.getInitParameter("name");
System.out.println("서블릿 초기값:"+name);
...
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
doGet(req, resp); // ← POST 도 GET 처리로 넘긴다
}
@Override
public void destroy() {
System.out.println("요청이 더이상 없으면 자동으로 서블릿 객체를 소멸시킨다."); // ← 실제로는 종료·재배포 때
}
}
JAVA올바른 초기화 — 둘 중 하나
// (1) init(ServletConfig) 를 재정의한다면 반드시 super 먼저
@Override
public void init(ServletConfig config) throws ServletException {
super.init(config); // ← config 저장 + init() 호출
String name = config.getInitParameter("name");
}
// (2) 더 간단한 방법: 인자 없는 init() 만 재정의
@Override
public void init() throws ServletException {
String name = getInitParameter("name"); // ← 저장된 config 를 통해 읽는다
}
실행 결과서버를 켜고 HelloServlet.do를 두 번 요청했을 때, HelloServlet이 콘솔에 찍는 줄 먼저 예측 → 펼쳐서 확인
[첫 요청]
서블릿 초기값:한경닷컴 ← init(ServletConfig) — 한 번만
hk ← application 속성
org.mariadb.jdbc.Driver ← web.xml 의 context-param
요청주소:http://.../03_hello_servlet/HelloServlet.do ← doGet
[두 번째 요청]
요청주소:http://.../03_hello_servlet/HelloServlet.do ← doGet 만. init 은 다시 안 불린다
* "init():최초 한번 실행" 은 어느 때도 찍히지 않는다.
* "요청이 더이상 없으면..." 은 서버를 정지하거나 재배포할 때 찍힌다.
이 줄들은 코드에서 그대로 정해지는 출력만 추린 것입니다. 실제 콘솔에는 EncodeFilter가 찍는 "요청할때 실행할 코드"·"응답할때 실행할 코드" 같은 줄도 사이사이 섞여 나옵니다 — 필터는 web-30에서 다룹니다.
관찰 포인트: 두 번째 요청에서 init 줄이 다시 나오지 않는 것이 "객체는 하나, 요청 처리는 여러 번"의 증거이고, 인자 없는 init() 줄이 한 번도 안 나오는 것이 super 호출 누락의 증거입니다.
28번 정리 — 핵심 정리
@WebServlet 또는 web.xml로 URL을 서블릿 클래스에 매핑한다.
생명주기: 생성 → init 1회 → 요청마다 service → destroy 1회.
service가 HTTP 방식에 따라 doGet/doPost로 나눈다. 수업처럼 doPost에서 doGet을 불러 한곳에서 처리하기도 한다.
destroy는 서버 종료·재배포 때 — 요청이 잠깐 없다고 호출되지 않는다.
init(ServletConfig)를 재정의하면 super.init(config) 필수 — 아니면 인자 없는 init()이 안 불리고 getServletConfig()가 null.
객체 하나를 여러 요청이 공유하므로 요청값을 인스턴스 필드에 저장하지 않는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
HelloServlet.do를 열 번 요청하면 HelloServlet 객체는 몇 개 생길까?
보통 하나입니다. 컨테이너가 첫 요청 때 한 번 만들고 init한 뒤, 이후 요청은 같은 객체의 service를 (필요하면 여러 스레드에서 동시에) 부릅니다. 그래서 init 출력은 한 번, doGet 출력은 열 번 나옵니다.
수업 코드에서 "init():최초 한번 실행"이 찍히지 않는 이유는?
인자 없는 init()을 부르는 쪽이 상위 클래스의 init(ServletConfig)인데, 수업 코드가 이 메서드를 재정의하면서 super.init(config)를 부르지 않았기 때문입니다. 컨테이너는 init(ServletConfig)만 부르므로, 연결 고리가 끊겨 init()까지 가지 못합니다.
요청 파라미터 param을 서블릿의 인스턴스 필드에 저장하면 왜 위험할까?
모든 요청이 같은 서블릿 객체를 공유하기 때문입니다. A와 B가 거의 동시에 요청하면 A의 값이 B의 값으로 덮여 A 화면에 B의 값이 찍힐 수 있어요. 요청별 값은 doGet 안의 지역변수에 둡니다.
💼 실무·코딩테스트에서는스프링 MVC도 내부적으로는 DispatcherServlet이라는 서블릿 하나가 모든 요청을 받는 구조입니다(web-39). 그래서 "서블릿 생명주기를 설명하라", "서블릿은 싱글턴처럼 동작하는데 왜 스레드 안전에 주의해야 하나"는 면접 단골이에요. 스프링의 @Controller·@Service 빈도 기본이 싱글턴이라, 필드에 요청별 상태를 두지 않는다는 규칙이 그대로 이어집니다.
한 줄 요약ServletConfig는 서블릿 하나의 설정, ServletContext는 웹 앱 전체가 공유하는 정보(JSP의 application), HttpSession은 사용자 한 명의 여러 요청에 걸친 정보다. 그리고 초기화 파라미터(설정 문자열)와 속성(실행 중 저장한 객체)을 구별한다.
쉽게 말하면ServletConfig는 직원 한 명의 업무 수첩, ServletContext는 가게 전체 공지판, Session은 손님별 사물함입니다. 수첩과 공지판에 미리 인쇄해 둔 안내문이 초기화 파라미터, 영업 중에 붙였다 뗐다 하는 메모가 속성이에요. 공지판에 손님 비밀번호를 붙이면 안 되겠죠.
① ServletConfig — 서블릿 하나의 설정
특정 서블릿에만 주는 init-param을 담습니다. @WebInitParam이나 web.xml의 <servlet> 안 <init-param>으로 선언하고, config.getInitParameter("name")으로 읽어요. 다른 서블릿은 이 값을 볼 수 없습니다. JSP의 내장 객체 config가 이것입니다.
② ServletContext — 웹 앱 하나에 하나
웹 앱 단위 공유 객체로, web.xml의 <context-param>(모든 서블릿이 읽는 설정)과 application 속성(setAttribute로 넣는 공유 객체)을 제공합니다. config.getServletContext()나 getServletContext()로 얻고, JSP에서는 application이라는 이름입니다.
③ HttpSession — 사용자 한 명의 여러 요청
request.getSession()은 이 사용자의 세션이 있으면 돌려주고 없으면 새로 만듭니다. 서버는 세션마다 ID를 붙여 JSESSIONID 쿠키로 브라우저에 보내고, 다음 요청에 그 쿠키가 오면 같은 세션을 찾아요. 수업 코드의 session.setAttribute("id", "hk")는 세션 동작을 보는 예제이지 로그인 기능 자체가 아닙니다.
JAVA03_hello_servlet/HelloServlet.java — 세 객체를 한 번씩
실행 결과HelloServlet.do를 요청한 뒤 브라우저 개발자 도구(Application → Cookies)를 보면 먼저 예측 → 펼쳐서 확인
이름 값
JSESSIONID (서버가 만든 긴 무작위 문자열)
* 세션에 넣은 "id"="hk" 는 쿠키에 없다 — 값은 서버의 세션 객체에 있고,
브라우저에는 그 세션을 찾을 "열쇠(ID)"만 온다.
"세션에 저장한다"는 것은 서버 메모리에 저장한다는 뜻입니다. 브라우저가 들고 있는 건 JSESSIONID라는 번호표뿐이에요. 그래서 이 쿠키를 지우면 서버의 세션 객체는 남아 있어도 다시 찾아갈 수 없어 새 세션이 만들어집니다. 쿠키 값은 사람마다·실행마다 다르니 그대로 비교하지 말고 "JSESSIONID가 생겼는지"만 확인하세요.
참고로 JSP는 기본 설정(session="true")에서 페이지를 열 때 세션을 자동으로 만들기 때문에, index.jsp를 연 순간 이미 JSESSIONID가 생겨 있을 수 있습니다. 그 경우 HelloServlet의 getSession()은 새로 만들지 않고 그 세션을 돌려줍니다.
29번 정리 — 핵심 정리
ServletConfig = 서블릿 하나의 init-param (@WebInitParam / <init-param>).
ServletContext = 웹 앱 전체 — <context-param>과 application 속성. JSP의 application.
아니요, null입니다. "id"는 setAttribute로 넣은 속성이라 getAttribute("id")로 꺼내야 하고, getInitParameter는 web.xml의 context-param만 읽습니다. 파라미터와 속성이 별개라는 점은 request에서도 똑같았죠(web-22).
💼 실무·코딩테스트에서는스프링에서는 web.xml의 context-param으로 루트 설정 파일 위치를 알려 주는 식으로 이 구조가 그대로 쓰이고(web-39), 로그인 정보는 세션(또는 토큰)에 담습니다. 면접에서는 "쿠키와 세션의 차이"를 자주 묻는데, 핵심은 "값이 어디에 저장되나 — 쿠키는 브라우저, 세션은 서버(브라우저엔 ID만)"입니다. 서버가 여러 대면 세션 공유 문제가 생긴다는 것까지 말하면 좋습니다.
30
인코딩 Filter와 요청 흐름 — chain.doFilter의 앞과 뒤
Filter@WebFilterchain.doFilterUTF-8
한 줄 요약한글 인코딩처럼 모든 요청에 똑같이 해야 하는 일은 서블릿마다 쓰지 않고 Filter 한 곳에 둔다. Filter는 서블릿보다 먼저 실행되고, chain.doFilter() 앞은 요청 때, 뒤는 응답 때 실행된다.
쉽게 말하면건물 입구 검사대(Filter)에서 모든 방문객에게 UTF-8 통역 배지를 달아 주고 들여보내는 것입니다. 방마다(서블릿마다) 배지를 나눠 줄 필요가 없어지죠. 검사대 직원이 chain.doFilter로 문을 열어 주지 않으면 아무도 안으로 못 들어가고, 볼일을 마친 방문객은 나갈 때 같은 검사대를 다시 지나갑니다.
03의 HelloServlet을 보면 doGet 안에 request.setCharacterEncoding("utf-8") 두 줄이 주석으로 남아 있습니다. 서블릿이 늘어날 때마다 이 두 줄을 복사해야 했던 것을 필터 하나로 옮긴 흔적이에요. 04부터는 EncodeFilter가 /*(모든 주소)에 걸려 있어서 BoardController까지 오는 요청은 이미 UTF-8로 맞춰진 상태입니다.
문자 인코딩은 파라미터를 읽기 전에, 응답 형식은 writer를 얻기 전에 정한다. chain 호출 앞은 요청 전처리, 뒤는 다음 처리에서 돌아온 뒤의 작업이다. 호출을 생략하면 Servlet까지 도달하지 않는다. 수업 예제는 HTML 응답 실습이다. 실제 API·이미지 응답에도 text/html을 일괄 강제하는 방식은 그대로 쓰지 않는다.
JAVA04 EncodeFilter.java — 모든 요청의 앞뒤를 감싸는 필터
@WebFilter(
urlPatterns = {"/*"}, // ← 모든 요청이 이 필터를 먼저 지난다
initParams = {
@WebInitParam(name="encoding", value="utf-8") // ← 초기값: 인코딩 이름
}
)
public class EncodeFilter extends HttpFilter {
private String encode;
@Override
public void init(FilterConfig config) throws ServletException {
encode = config.getInitParameter("encoding"); // ← "utf-8"을 한 번 읽어 둔다
}
@Override
public void doFilter(ServletRequest request,
ServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("요청할때 실행할 코드");
request.setCharacterEncoding(encode); // ← 파라미터를 읽기 전에
response.setContentType("text/html;charset=" + encode); // ← writer를 얻기 전에
// ServletRequest(상위)에는 getRequestURI()가 없다 → HTTP 전용 자식 타입으로 형변환
HttpServletRequest httpReq = (HttpServletRequest) request;
System.out.println("요청URI(filter):" + httpReq.getRequestURI());
chain.doFilter(request, response); // ← 다음 필터·서블릿으로 넘긴다
System.out.println("응답할때 실행할 코드"); // ← 서블릿이 끝나고 돌아온 뒤
}
}
XML03 web.xml — 같은 필터를 XML로 등록하는 방법(수업 파일에 주석으로 남아 있다)
<filter>
<filter-name>EncodeFilter</filter-name>
<filter-class>com.hk.hello.EncodeFilter</filter-class>
<init-param> <!-- ← @WebInitParam 과 같은 역할 -->
<param-name>encoding</param-name>
<param-value>utf-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>EncodeFilter</filter-name>
<url-pattern>/*</url-pattern> <!-- ← urlPatterns = {"/*"} 와 같다 -->
</filter-mapping>
실행 결과04에서 boardlist.board를 한 번 요청하면 콘솔에 찍히는 순서 먼저 예측 → 펼쳐서 확인
요청할때 실행할 코드 ← EncodeFilter (chain 앞)
요청URI(filter):/04_hkboard_MVC2/boardlist.board
/04_hkboard_MVC2/boardlist.board ← BoardController.doGet 의 println 3줄
/04_hkboard_MVC2
null
1단계:드라이버 로딩 성공 ← new HkDao() → DataBase 생성자
응답할때 실행할 코드 ← EncodeFilter (chain 뒤)
이 순서는 필터·컨트롤러·DataBase 코드의 println 위치로 정해집니다. 컨텍스트 경로는 이클립스 기본값(프로젝트 이름)으로 배포했을 때 기준이에요.
"응답할때" 줄이 맨 마지막이라는 점이 핵심입니다. chain.doFilter()가 컨트롤러와 JSP 출력까지 모두 끝난 뒤에야 돌아오기 때문이죠.
컨트롤러가 boardlist.jsp로 forward 할 때는 필터가 다시 돌지 않습니다. @WebFilter는 기본으로 브라우저에서 들어온 요청(REQUEST)에만 걸리기 때문입니다. 반대로 브라우저가 아이콘(favicon) 같은 주소를 따로 요청하면 /*에 걸려 그 요청 몫의 줄이 더 찍힐 수 있습니다.
30번 정리 — 핵심 정리
Filter는 서블릿 앞에서 실행되는 공통 처리 자리다 — 인코딩, 로그인 확인, 요청 로그 등.
등록 방법은 두 가지: @WebFilter(urlPatterns="/*") 또는 web.xml의 <filter> + <filter-mapping>.
chain.doFilter 앞 = 요청 전처리, 뒤 = 응답 후처리. 호출을 빼먹으면 서블릿에 도달하지 않는다.
setCharacterEncoding은 POST 본문을 읽는 방식을 정한다. 그래서 파라미터를 하나라도 읽기 전에 불러야 한다.
ServletRequest는 프로토콜 공통 타입이라 getRequestURI()가 없다 — HttpServletRequest로 형변환해서 쓴다.
스프링(06)에서는 같은 일을 CharacterEncodingFilter를 web.xml에 등록해 처리한다. → 🖥️ 39. 스프링 요청 흐름
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
EncodeFilter에서 chain.doFilter(request, response) 한 줄을 지우면 어떻게 되나?
요청이 필터에서 멈춥니다. 콘솔에는 "요청할때"와 "응답할때" 줄만 찍히고 컨트롤러의 출력은 나오지 않으며, 브라우저는 아무 내용도 없는 응답을 받습니다. 에러가 나지 않아서 더 찾기 어려운 실수예요. 반대로 이 성질을 이용해 로그인하지 않은 요청을 필터에서 돌려보내는 것이 로그인 필터입니다.
03의 EncodeFilter에는 doFilter(HttpServletRequest, …) 버전도 있는데, 왜 그 메서드는 실행되지 않나?
톰캣은 Filter 인터페이스의 doFilter(ServletRequest, ServletResponse, FilterChain)을 부릅니다. 원래 HttpFilter의 이 메서드가 형변환한 뒤 HTTP 버전을 대신 불러 주는 다리 역할을 하는데, 03은 이 메서드를 직접 재정의해서 그 다리가 끊겼습니다. 그래서 HTTP 버전은 불리지 않는 코드로 남아 있어요. 04에서는 그 메서드를 지우고 하나만 남겼습니다.
필터 없이 서블릿에서 getParameter("title")를 먼저 부르고 그다음에 setCharacterEncoding("utf-8")을 부르면?
POST로 보낸 한글이 깨집니다. 파라미터는 처음 읽는 순간 그때의 인코딩으로 한 번에 해석되고, 그 뒤에 인코딩을 바꿔도 다시 해석하지 않기 때문입니다. 필터는 서블릿이 무엇이든 읽기 전에 실행되니 이 순서 실수를 구조적으로 막아 줍니다.
💼 실무·코딩테스트에서는필터는 인코딩보다 로그인 확인, 요청 로그, CORS, 보안 검사에 더 많이 쓰입니다. 스프링 시큐리티도 결국 여러 필터를 줄 세운 필터 체인이에요. 면접에서는 "Filter와 Interceptor의 차이"가 자주 나오는데, Filter는 서블릿 컨테이너(톰캣) 수준에서 DispatcherServlet 앞에, Interceptor는 스프링 MVC 안에서 컨트롤러 앞뒤에 걸린다고 답하면 됩니다.
31
Servlet 프런트 컨트롤러 — URI로 분기하기
MVC2프런트 컨트롤러getRequestURI*.board
한 줄 요약요청 분기를 JSP에서 Servlet 하나로 옮긴다(MVC2). *.board로 끝나는 모든 요청이 BoardController 한 곳으로 모이고, 요청 주소(URI)에서 컨텍스트 경로를 잘라낸 나머지로 어떤 일을 할지 고른다.
쉽게 말하면건물 입구에 안내 데스크(프런트 컨트롤러)를 하나만 두고, 방문객이 내민 주소 쪽지에서 건물 이름(컨텍스트 경로)을 뺀 나머지("/boardlist.board")만 보고 담당 창구로 보내는 것입니다. 02에서는 쪽지 안에 적힌 메모(command 파라미터)를 보고 보냈다면, 이제는 주소 자체를 보고 보냅니다.
02의 boardController.jsp는 ?command=boardlist처럼 파라미터 값으로 분기했습니다(→ 🖥️ 18. command 분기). 04는 @WebServlet("*.board")로 확장자 매핑을 걸어, 주소가 .board로 끝나면 무조건 이 서블릿이 받게 했어요. 흐름을 정하는 일은 자바 클래스가, 화면을 그리는 일은 JSP가 맡는 것 — 이것이 MVC2입니다. 컨텍스트 경로는 배포 환경마다 바뀔 수 있으므로 문자열로 박아 넣지 않고 길이만큼 잘라낸다.
JAVA04 BoardController.java — 주소로 갈라 DAO를 부르고 화면으로 넘긴다
@WebServlet("*.board") // ← .board로 끝나는 요청은 전부 여기로
public class BoardController extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String requestURI = request.getRequestURI(); // "/04_hkboard_MVC2/boardlist.board"
String contextPath = request.getContextPath(); // "/04_hkboard_MVC2"
String pathInfo = request.getPathInfo();
System.out.println(requestURI + "\n" + contextPath + "\n" + pathInfo);
String command = requestURI.substring(contextPath.length()); // ← "/boardlist.board"
HkDao dao = new HkDao();
if (command.equalsIgnoreCase("/boardlist.board")) { // 글목록
List<HkDto> list = dao.getAllList();
request.setAttribute("list", list); // ← 화면에 넘길 객체
// response.sendRedirect("boardlist.jsp"); // 객체 전달 못함 (X)
dispatch("boardlist.jsp", request, response); // ← forward
} else if (command.equalsIgnoreCase("/boardInsert.board")) { // 글추가
String id = request.getParameter("id");
String title = request.getParameter("title");
String content = request.getParameter("content");
boolean isS = dao.insertBoard(new HkDto(id, title, content));
if (isS) {
response.sendRedirect("boardlist.board"); // ← 등록 후에는 redirect
// forward를 쓰면 주소창이 갱신되지 않아 같은 양식을 또 제출하게 된다
} else {
response.sendRedirect("error.jsp");
}
} else if (command.equalsIgnoreCase("/boardDetail.board")) {
// ... getBoard(seq) → setAttribute("dto", dto) → forward
}
// /boardInsertForm · /boardUpdate · /boardDelete · /muldel.board 도 같은 방식
}
// forward 기능을 메서드 하나로 묶었다
public void dispatch(String url, HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.getRequestDispatcher(url).forward(request, response);
}
}
실행 결과주소창이 http://localhost:8080/04_hkboard_MVC2/boardlist.board일 때 println 세 줄 먼저 예측 → 펼쳐서 확인
getContextPath()는 프로젝트(웹 애플리케이션) 이름입니다. 이클립스가 기본으로 프로젝트 이름을 붙여 배포해서 이 값이 나옵니다.
getPathInfo()가 null인 것은 *.board 같은 확장자 매핑이기 때문입니다. /board/* 같은 경로 매핑일 때만 * 자리의 값이 들어옵니다.
31번 정리 — 핵심 정리
@WebServlet("*.board") — 확장자 매핑으로 모든 게시판 요청을 한 서블릿(프런트 컨트롤러)에 모은다.
command = requestURI.substring(contextPath.length()) — 컨텍스트 경로를 문자열로 박지 않고 길이만큼 잘라낸다.
04 BoardController는 목록·글쓰기 폼·등록·상세·수정·삭제·다중 삭제(/muldel.board)를 모두 이 방식으로 분기한다.
조회 결과를 화면에 넘길 때는 setAttribute + forward — forward는 같은 request의 객체를 화면으로 넘긴다.
등록·수정·삭제 뒤에는 redirect — 목록 URL에 새 요청을 보내, 새로고침해도 같은 글이 다시 등록되지 않는다.
JSP 링크와 폼 action도 boardDetail.board?seq=3처럼 .board 주소를 가리켜야 이 서블릿을 거친다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
글목록 분기에서 dispatch 대신 response.sendRedirect("boardlist.jsp")를 쓰면 04에서는 어떻게 되나?
HTTP 500(NullPointerException)이 납니다. redirect는 브라우저에게 "이 주소로 새로 요청하라"고 돌려보내는 것이라, 방금 setAttribute로 담은 list는 이전 request와 함께 사라집니다. 04의 boardlist.jsp는 for(HkDto dto : list)로 바로 돌기 때문에 list가 null이면 거기서 터지죠. 소스의 // 객체 전달 못함 (X) 주석이 바로 이 이야기입니다.
배포할 때 컨텍스트 경로가 "/board"로 바뀌면 command를 구하는 코드를 고쳐야 하나?
고칠 필요가 없습니다.getContextPath()가 "/board"를 돌려주고, 그 길이만큼 잘라내므로 command는 여전히 "/boardlist.board"입니다. 만약 "/04_hkboard_MVC2".length()처럼 프로젝트 이름을 직접 적었다면 배포 환경이 바뀌는 순간 모든 분기가 어긋났을 거예요.
글 등록 성공 후 forward로 목록을 보여 주면 무엇이 문제인가?
주소창이 boardInsert.board로 남습니다. 이 상태에서 사용자가 새로고침(F5)을 누르면 브라우저는 방금 보낸 POST 양식을 다시 제출하고, 같은 글이 한 번 더 등록되죠. redirect로 목록 주소에 새 요청을 보내면 주소창이 boardlist.board로 바뀌어 새로고침해도 목록 조회만 다시 일어납니다.
💼 실무·코딩테스트에서는요청을 한 곳에 모아 나눠 주는 구조를 프런트 컨트롤러 패턴이라 하고, 스프링의 DispatcherServlet이 바로 이 역할을 합니다(→ 🖥️ 39). 지금처럼 if-else가 길어지는 불편을 @RequestMapping이 대신 풀어 주죠. 면접에서는 "forward와 redirect의 차이", 그리고 "저장 뒤 redirect"를 가리키는 PRG(Post-Redirect-Get) 패턴이 자주 나옵니다.
정리 작업 메모
9월 18일 수업 시점에는 목록·등록까지만 전환되어 있었고, 상세·수정·삭제 분기는 이전 command 이름을 비교하고 있었다. 현재 04 프로젝트의 BoardController는 모든 분기가 /xxx.board 형태로 바뀌어 있다.
한 줄 요약Controller가 Scope에 담아 둔 객체를 자바 코드 없이 ${dto.title} 한 줄로 꺼낸다. import도, getAttribute도, 형변환도 사라진다.
쉽게 말하면${dto.title}은 "보관함에서 dto를 꺼내 제목을 보여 달라"는 주문서입니다. 어느 보관함(page·request·session·application)에 있는지 찾고, 꺼내서, 제목을 읽는 과정은 EL이 알아서 하죠. 못 찾으면 화를 내지 않고 조용히 빈칸으로 둡니다.
04의 JSP는 값 하나를 찍으려면 세 단계가 필요했습니다 — import로 클래스를 알리고, request.getAttribute("dto")로 꺼내 (HkDto)로 형변환하고, <%= dto.getTitle() %>로 출력. 05에서는 Controller 코드를 한 글자도 바꾸지 않고 JSP 쪽만 ${requestScope.dto.title}으로 바꿨습니다. Controller가 setAttribute로 붙인 이름이 화면과 연결되는 유일한 끈이에요.
<%= dto.getTitle() %> title 이 null → 화면에 null 이라는 글자
${dto.title} title 이 null → 아무것도 출력하지 않음
<%= dto.getTitle() %> dto 자체가 null → NullPointerException (HTTP 500)
${dto.title} dto 자체가 null → 아무것도 출력하지 않음
EL은 "화면용 문법"이라 화면에 맞게 동작합니다. 비어 있으면 빈칸이 자연스럽죠. <%= %>는 자바의 String.valueOf처럼 동작해서 null을 글자 그대로 찍습니다. → 🖥️ 16. MVC1의 한계에서 남겨 둔 "null이 화면에 나오는 문제"가 여기서 풀립니다.
다만 조용한 것이 늘 좋은 건 아닙니다. 이름을 틀려도(${dot.title}) 에러 없이 빈칸이라, "왜 안 나오지?"를 찾는 데 시간이 걸릴 수 있어요.
32번 정리 — 핵심 정리
${이름}은 page → request → session → application 순으로 찾는다. 못 찾아도 예외가 아니라 빈 문자열이다.
${requestScope.dto.title}처럼 Scope를 직접 지정할 수도 있다. → 🖥️ 21. 네 가지 scope
${dto.title}은 필드가 아니라 getTitle()을 호출한다. getter 이름 규칙이 그대로 화면 문법이 된다 — getter가 없으면 오류가 난다.
값이 null일 때 <%= %>는 "null"을 찍지만, EL은 아무것도 출력하지 않는다.
담는 쪽 이름 = 꺼내는 쪽 이름.request.setAttribute("list", …)로 담았으면 ${list}로 꺼낸다.
EL은 서버에서 글자로 바뀌어 HTML에 박힌다 — 그래서 value="…"나 onclick="…" 안에서도 쓸 수 있다.
05 boardDetail.jsp 맨 위의 HkDto dto = … 선언은 본문이 EL로 바뀌어 더 이상 쓰이지 않는다 — 지워도 화면은 같다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
에러 없이 빈칸이 나옵니다. EL은 네 Scope 어디에서도 dto라는 이름을 찾지 못하고, 못 찾으면 조용히 빈 문자열을 돌려주니까요. 화면 연결은 오직 이름으로 이루어지기 때문에, 값이 안 나오면 담는 쪽과 꺼내는 쪽의 이름부터 맞춰 봐야 합니다.
${dto.regDate}는 HkDto의 무엇을 부르나? 필드 이름을 regdate로 바꾸면 영향이 있나?
getRegDate()를 부릅니다. EL은 regDate → 첫 글자를 대문자로 → get을 붙여 getter를 찾습니다. 그래서 getter 이름이 그대로면 필드 이름이 바뀌어도 화면은 영향을 받지 않아요. 반대로 getter를 지우거나 이름을 바꾸면 EL 쪽이 깨집니다.
💼 실무·코딩테스트에서는EL의 ${…}은 HTML 특수문자를 바꾸지 않고 그대로 출력합니다. 사용자가 글 제목에 <script>를 넣으면 다른 사람 화면에서 그대로 실행되는 XSS가 생길 수 있어요. 실무에서는 사용자 입력을 찍을 때 <c:out value="${dto.title}"/>처럼 이스케이프해 주는 출력을 씁니다. Thymeleaf 같은 요즘 템플릿 엔진은 기본으로 이스케이프한다는 점과 비교해 두면 면접에서도 좋은 답이 됩니다.
33
JSTL 설치 — jar 2개와 jakarta.tags.core
JSTLtaglibjakarta.tags.coreWEB-INF/lib
한 줄 요약JSTL은 JSP의 표준 태그 라이브러리지만 Tomcat에 들어 있지 않다. API·구현 jar 2개를 WEB-INF/lib에 직접 넣고, JSP 맨 위에 새 주소(jakarta.tags.core)로 taglib를 선언해야 <c:…> 태그를 쓸 수 있다.
쉽게 말하면JSTL은 설명서(API jar)와 실제 기계(구현 jar)를 함께 들여와야 돌아가는 가전입니다. 설명서만 있으면 버튼 이름은 알아도 누르면 아무 일도 안 일어나죠. 그리고 콘센트 규격(taglib 주소)이 Tomcat 10부터 바뀌어서, 옛날 예제의 주소를 그대로 꽂으면 맞지 않습니다.
EL(${…})은 JSP 엔진에 들어 있어 바로 쓸 수 있었지만, 반복·분기 같은 태그는 JSTL이 따로 제공합니다. 톰캣은 JSP를 실행하는 엔진만 갖고 있고 JSTL 구현은 포함하지 않아서, 웹 애플리케이션이 직접 챙겨야 해요. WEB-INF/lib는 그 웹 애플리케이션 전용 jar 자리라, 여기 넣은 jar는 05 프로젝트에서만 쓰입니다.
JSP05 — jar 위치와 boardlist.jsp 맨 위의 taglib 선언
src/main/webapp/WEB-INF/lib/
jakarta.servlet.jsp.jstl-api-3.0.1.jar <-- 규격(API)
jakarta.servlet.jsp.jstl-3.0.1.jar <-- 구현체 (둘 다 있어야 한다)
<%-- boardlist.jsp --%>
<%@ taglib prefix="c" uri="jakarta.tags.core" %> <%-- ← Tomcat 10 이상(JSTL 3.0) 주소 --%>
<%-- index.jsp : 날짜·숫자 형식용 fmt 도 선언해 두었다(05에서는 아직 안 쓴다) --%>
<%@taglib uri="jakarta.tags.fmt" prefix="fmt" %>
<%-- Tomcat 9 이하 예제에 나오는 옛 URI (10에서는 태그를 못 찾는다) --%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
Tomcat 10부터 javax → jakarta로 바뀐 여파가 taglib URI에도 온다. 인터넷 예제를 그대로 붙이면 태그가 문자 그대로 출력되거나 오류가 난다.
prefix="c"는 태그 앞에 붙일 별명이다. 관례상 core는 c, 형식(fmt)은 fmt를 쓴다.
taglib 선언은 JSP 파일마다 필요하다 — 다른 파일에 선언했다고 이 파일에서 쓸 수 있는 게 아니다.
이클립스 편집기에 Unknown tag (c:forEach) 표시가 남는 건 검증기가 jar를 아직 못 읽은 것이다. Refresh · Clean 으로 없어지며 실제 실행과는 별개다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
jar는 잘 넣었는데 boardlist.jsp에서 taglib 줄만 빠뜨리면 <c:forEach>는 어떻게 되나?
태그로 해석되지 않습니다. JSP는 c:라는 접두사가 무엇인지 모르니 <c:forEach …>를 그냥 HTML 글자로 보고 브라우저에 내보내요. 브라우저도 모르는 태그라 무시하므로 반복 없이 안쪽 내용이 한 번만 빈칸으로 찍히는 등 엉뚱한 화면이 나옵니다. 에러가 안 나서 헷갈리기 쉬운데, 페이지 소스 보기를 하면 <c:forEach>가 그대로 보여서 바로 알 수 있습니다.
06 같은 Maven 프로젝트에서도 JSTL jar를 WEB-INF/lib에 직접 복사해야 하나?
아닙니다.pom.xml에 선언하면 Maven이 jar를 내려받고, war를 만들 때 알아서 WEB-INF/lib에 넣어 줍니다. JSTL은 서블릿 API와 달리 톰캣이 갖고 있지 않으므로<scope>provided</scope>를 붙이지 않는다는 점도 차이예요. → 🖥️ 38. pom.xml
💼 실무·코딩테스트에서는새 프로젝트는 JSP 대신 Thymeleaf나 React 같은 화면 기술을 쓰는 경우가 많지만, 운영 중인 시스템에는 JSP + JSTL이 여전히 많습니다. 그리고 javax → jakarta 전환은 지금도 실무에서 자주 만나는 일이에요 — 스프링 부트 3·스프링 6부터 jakarta만 지원해서, 오래된 프로젝트를 올릴 때 import·taglib·의존성을 한꺼번에 바꿔야 합니다. 05에서 겪은 taglib URI 문제가 그 축소판입니다.
34
<c:forEach> — 목록 반복에서 for문 걷어내기
c:forEachitemsvarvarStatus
한 줄 요약목록 화면의 스크립틀릿 for문을 <c:forEach items="${list}" var="dto">로 바꾼다. HTML 사이에 끼어 있던 자바 중괄호가 사라지고, 여닫는 태그 짝이 눈에 보인다.
쉽게 말하면목록을 넣으면 줄마다 같은 양식을 찍어 주는 도장과 같습니다. items에 목록을 주고 var에 "한 줄을 부를 이름"을 정해 주면, 안쪽 양식이 글 개수만큼 찍히죠. 목록이 비어 있거나 아예 없으면(null) 도장을 한 번도 찍지 않고 조용히 지나갑니다.
04의 boardlist.jsp는 <% for(HkDto dto:list){ %>로 시작해 <% } %>로 끝났습니다. 그 사이의 HTML은 자바 블록 안에 갇혀 있어서 어느 }가 어느 블록을 닫는지 눈으로 따라가야 했죠. 05는 Controller가 담은 list를 그대로 두고, 화면만 <c:forEach>로 바꿨습니다. 그 위에 있던 getAttribute·형변환 줄도 함께 사라졌습니다.
JSPboardlist.jsp — 04(스크립틀릿 for)와 05(c:forEach) 같은 표
<%-- ===== 04_hkboard_MVC2 ===== --%>
<%
List<HkDto> list = (List<HkDto>)request.getAttribute("list"); // ← 꺼내고 형변환
%>
<%
for(HkDto dto:list){ // ← list 가 null 이면 여기서 NPE
%>
<tr>
<td><input type="checkbox" name="seq" value="<%=dto.getSeq()%>" /></td>
<td><%=dto.getSeq()%></td>
<td><%=dto.getId()%></td>
<td><a href="boardDetail.board?seq=<%=dto.getSeq()%>"><%=dto.getTitle()%></a></td>
<td><%=dto.getRegDate()%></td>
</tr>
<%
} // ← 어느 블록을 닫는지 안 보인다
%>
<%-- ===== 05_hkboard_MVC2_JSTL ===== --%>
<c:forEach items="${list}" var="dto"> <%-- ← list 를 한 줄씩 dto 라는 이름으로 --%>
<tr>
<td><input type="checkbox" name="seq" value="${dto.seq}" /></td>
<td>${dto.seq}</td>
<td>${dto.id}</td>
<td><a href="boardDetail.board?seq=${dto.seq}">${dto.title}</a></td>
<td>${dto.regDate}</td>
</tr>
</c:forEach> <%-- ← 닫는 짝이 이름으로 보인다 --%>
34번 정리 — 핵심 정리
items = 반복할 목록(EL로 Scope에서 꺼낸다), var = 한 줄을 부를 이름.
var="dto"가 만드는 변수는 page 스코프다. forEach 블록 밖에서는 쓸 수 없다.
varStatus="st"를 붙이면 ${st.index}(0부터) · ${st.count}(1부터)를 쓸 수 있다.
items가 null이면 JSTL은 0번 반복하고 넘어간다. 스크립틀릿 for문은 같은 상황에서 NullPointerException이 난다.
닫는 태그가 </c:forEach>라서 HTML 태그와 중첩 관계가 한눈에 맞춰진다. <% } %>는 어느 블록을 닫는지 보이지 않는다.
EL은 속성 안에서도 쓸 수 있다 — 체크박스 value와 상세 링크 주소에 ${dto.seq}가 그대로 들어간다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
Controller를 거치지 않고 주소창에 boardlist.jsp를 직접 치면 04와 05는 각각 어떻게 되나?
Controller를 안 거쳤으니 request에 list가 없습니다(null). 04는 for(HkDto dto:list)에서 NullPointerException → HTTP 500이 나고, 05는 <c:forEach>가 0번 반복하고 지나가 빈 표가 나옵니다(05에서는 <c:choose>가 "작성된 글이 없습니다"를 보여 줘요 → 🖥️ 35). 다만 05의 빈 표도 진짜로 글이 0건인 것과 구별되지 않는다는 점은 기억해 두세요.
var="dto"를 var="board"로 바꾸면 무엇을 함께 바꿔야 하나? items="${list}"도 바꿔야 하나?
안쪽의 ${dto.seq}·${dto.title} 등을 전부 ${board.…}로 바꿔야 합니다. var는 JSP 안에서 내가 정하는 이름이니까요. 반면 items="${list}"는 그대로입니다 — list는 Controller가 setAttribute("list", …)로 정해 둔 이름이라 JSP 마음대로 바꿀 수 없어요.
💼 실무·코딩테스트에서는"목록을 받아 한 줄 양식을 반복해 찍는다"는 생각은 모든 화면 기술에 똑같이 나옵니다 — Thymeleaf의 th:each, React의 list.map(…)이 모두 같은 일이에요. 면접에서 "JSP에서 스크립틀릿을 왜 지양하나"를 물으면 화면과 로직이 섞이고, 태그 짝이 안 보여 깨지기 쉽고, null에 약하다는 세 가지를 이 비교로 설명할 수 있습니다.
35
<c:choose>와 empty — 글이 없을 때
c:choosec:whenemptyc:if
한 줄 요약목록이 비었을 때 "작성된 글이 없습니다" 안내를, 아니면 목록을 보여 주는 분기를 <c:choose> · <c:when test="${empty list}"> · <c:otherwise>로 만든다. 태그 중첩 규칙을 어기면 500 오류가 난다.
쉽게 말하면가게 문에 휴무 팻말과 영업 중 팻말 중 하나만 거는 것입니다. 목록이 비었으면(empty) 안내 문구를, 아니면 목록을 보여 주죠. 팻말은 반드시 choose라는 틀 안에 걸어야 하고, 틀 밖에 걸린 팻말은 톰캣이 받아 주지 않습니다.
JSTL에는 else가 없습니다. 조건 하나만 볼 때는 <c:if>를 쓰고, "이것 아니면 저것"처럼 양쪽이 필요하면 <c:choose> 안에 <c:when>(자바의 if·else if)과 <c:otherwise>(자바의 else)를 넣습니다. 조건은 test 속성에 EL로 쓰고, <c:when>이 여러 개면 위에서부터 처음 참인 것 하나만 실행돼요.
JSP05 boardlist.jsp — 빈 목록 안내와 목록을 하나만 고른다
<c:choose>
<c:when test="${empty list}"> <%-- ← null · 빈 목록 모두 true --%>
<tr>
<td colspan="5">--작성된 글이 없습니다.--</td>
</tr>
</c:when>
<c:otherwise> <%-- ← 자바의 else --%>
<c:forEach items="${list}" var="dto">
<tr>
<td><input type="checkbox" name="seq" value="${dto.seq}" /></td>
<td>${dto.seq}</td>
...
</tr>
</c:forEach>
</c:otherwise>
</c:choose> <%-- ← when·otherwise 를 모두 감싼 뒤에 닫는다 --%>
실행 결과</c:choose>를 <c:otherwise>보다 먼저 닫았을 때 실제로 본 오류 먼저 예측 → 펼쳐서 확인
HTTP 500 – 내부 서버 오류
Illegal use of <when>-style tag without <choose> as its direct parent
05 작업 중(35일차)에 목록 화면이 실제로 이 오류로 500이 났습니다.</c:choose>가 <c:otherwise>보다 앞에 있어서, <c:otherwise>가 부모(choose) 없이 혼자 남은 상태였어요. 메시지 그대로 "when 계열 태그를 choose가 직접 부모가 아닌 곳에서 썼다"는 뜻입니다.
HTML은 태그를 잘못 닫아도 브라우저가 대충 보여 주지만, JSTL 태그는 서버가 JSP를 번역할 때 검사하므로 짝이 틀리면 화면 자체가 뜨지 않습니다. </c:choose>의 위치를 맨 끝으로 옮겨 고쳤습니다.
35번 정리 — 핵심 정리
empty는 null · 빈 문자열 · 빈 컬렉션을 모두 true로 본다. 크기를 따로 재지 않는다. 반대는 ${not empty list}.
<c:when>과 <c:otherwise>는 <c:choose>의 직속 자식이어야 한다.</c:choose>를 <c:otherwise>보다 먼저 닫으면 Illegal use of <when>-style tag without <choose> as its direct parent 500 오류가 난다. 2026-09-21에 실제로 겪었다.
<c:when>이 여러 개면 처음 참인 하나만 실행된다.
분기가 하나뿐이면 <c:if test="...">를 쓴다. JSTL에는 else가 없어서 양쪽이 필요하면 choose를 쓴다.
안내 문구 칸의 colspan="5"는 표의 열 수(체크박스·번호·작성자·제목·작성일)와 맞춘 것이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
test="${empty list}" 대신 test="${list == null}"을 쓰면 글이 0건일 때 안내 문구가 나올까?
나오지 않습니다. 05의 getAllList()는 처음부터 new ArrayList<>()를 만들어 돌려주므로, 글이 0건이어도 list는 null이 아니라 비어 있는 목록이에요. == null은 false가 되어 otherwise로 가고, forEach가 0번 돌아 아무것도 없는 표만 남습니다. empty는 null과 빈 목록을 모두 잡아 줍니다.
DB 접속이 실패했을 때 05 목록 화면에는 무엇이 보일까?
"--작성된 글이 없습니다.--"가 보입니다. getAllList()가 예외를 catch에서 처리하고 빈 목록을 그대로 돌려주기 때문이에요. 화면만 봐서는 "글이 0건"과 "DB 고장"을 구별할 수 없죠. 그래서 05에서는 catch에 작업 이름과 SQLState를 남기는 로그를 넣었습니다. → 🖥️ 37. 조용히 실패하는 코드
💼 실무·코딩테스트에서는목록이 비었을 때의 화면(빈 상태, empty state)은 실무 화면 설계의 기본 항목입니다. 표가 텅 비어 있으면 사용자는 "고장 났나?"부터 의심하니까요. 코딩테스트에서도 같은 습관이 통합니다 — 입력이 0개·빈 문자열일 때를 먼저 따져 보는 것이 경계값 실수를 막는 가장 쉬운 방법입니다.
36
04 → 05 핵심 정리 — 바뀐 것은 View 계층뿐
리팩터링View 계층ELJSTL
한 줄 요약05는 기능 추가가 아니라 화면을 그리는 방법만 바꾼 리팩터링이다. Controller·DTO·Filter는 그대로라서, 04와 나란히 놓으면 EL·JSTL이 무엇을 대신해 주는지가 선명하게 드러난다.
쉽게 말하면가구(Controller·DAO·DTO)는 그대로 두고 벽지(JSP 화면)만 새로 바른 리모델링입니다. 집의 구조가 같으니 "벽지를 바꿨더니 무엇이 달라졌나"만 보면 되죠. 바뀐 것은 화면을 그리는 방법과, 새 벽지를 붙이는 데 필요한 JSTL jar 2개뿐입니다.
JSTL로 바꾸면서 손댄 곳은 View(JSP) 계층뿐이다. Controller의 분기 구조와 DTO는 그대로이고, 라이브러리는 WEB-INF/lib의 JSTL jar 2개가 늘었다. (이후 05의 DAO·DataBase에는 오류를 알아보기 쉽게 하는 디버깅 개선이 추가되었다 → web-37)
값 출력: <%= dto.getTitle() %> → ${dto.title}
반복: 스크립틀릿 for → <c:forEach>
분기: 스크립틀릿 if → <c:choose> · <c:when> · <c:otherwise>
import 지시자와 (List<HkDto>) 형변환: 필요 → 불필요
null 값: 화면에 "null" 출력 → 아무것도 출력하지 않음
필요한 라이브러리: 없음 → JSTL API · 구현 jar 2개
그래서 배우는 점은 "화면에서 자바 문법을 빼면 무엇이 좋아지는가" 하나로 모인다. 디자이너가 열어도 HTML 구조가 보이고, 태그 짝이 맞는지 편집기가 검사해 줄 수 있다. MVC2가 요청 분기를 JSP 밖으로 뺀 단계였다면, JSTL은 화면 로직을 자바 밖으로 빼는 단계다. 같은 "분리"를 서로 다른 축에서 한 번씩 한 셈이다. → MVC2 전환 · EL
TEXT04 → 05 파일별로 본 차이 (JSTL 전환 시점)
━━━ 바뀐 것 (View) ━━━
boardlist.jsp 스크립틀릿 for → <c:choose> + <c:forEach> + ${dto.xxx}
boardDetail.jsp <%=dto.getXxx()%> → ${requestScope.dto.xxx} (5군데)
index.jsp taglib 선언 2줄 추가 (c, fmt)
WEB-INF/lib + JSTL jar 2개 (API · 구현)
boardController.jsp 04에 남아 있던 02 시절의 JSP 컨트롤러 — 05에는 없다
━━━ 그대로인 것 ━━━
BoardController *.board 분기 · forward · redirect
HkDto 필드 · getter (EL 이 getter 를 부르므로 그대로 쓸 수 있다)
EncodeFilter UTF-8 처리
boardInsertForm.jsp · error.jsp
HkDao · DataBase ← 이후 05에만 디버깅 개선이 들어가 지금은 다르다 (→ web-37)
JSP05 boardDetail.jsp 맨 위 — 아직 남아 있는 자바 두 줄
<%@page import="com.hk.board.dto.HkDto"%> <%-- ← 이제 필요 없다 --%>
<%
// requestScope에서 HkDto객체 가져오기
HkDto dto =(HkDto)request.getAttribute("dto"); // ← 선언만 있고 아래에서 쓰지 않는다
%>
...
<td>${requestScope.dto.id}</td> <%-- 본문은 이미 EL 이 직접 꺼낸다 --%>
36번 정리 — 핵심 정리
05 = 04 복사 + View만 교체. 기능은 하나도 늘지 않았다.
Controller가 setAttribute로 붙인 이름(list, dto)은 그대로라서, JSP는 그 이름을 EL로 부르기만 하면 됐다.
DTO의 getter 이름 규칙 덕분에 ${dto.title}이 getTitle()로 바로 연결된다 — DTO를 고칠 필요가 없었다.
"EL·JSTL로 바꿨다"와 "스크립틀릿이 한 줄도 없다"는 다르다. 05의 boardDetail.jsp에는 쓰이지 않는 선언 두 줄이 남아 있다.
한 번에 한 가지만 바꾸면 비교가 쉽다 — 04와 05의 차이가 전부 .jsp 안에 있으니, 문제가 생겨도 볼 곳이 정해진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
05 boardDetail.jsp 맨 위의 HkDto dto = … 두 줄을 지우면 화면이 달라지나?
달라지지 않습니다. 본문이 ${requestScope.dto.…}로 request에서 직접 꺼내고 있어서, 스크립틀릿의 지역변수 dto는 아무도 쓰지 않아요. EL은 애초에 스크립틀릿 지역변수를 보지 못하고 네 Scope만 찾습니다. 두 줄을 지우면 이 파일에서도 자바가 완전히 빠집니다.
JSTL로 바꾼 뒤 05 글쓰기가 error.jsp로 튕긴다. JSTL 탓인지 어떻게 빨리 가려낼까?
같은 요청을 04에도 보내 봅니다. 04와 05는 화면만 다르므로, 04도 똑같이 실패하면 원인은 JSTL이 아니라 둘이 공유하는 부분(DB 접속 등)입니다. 실제로 35일차에 이 방법으로 원인이 DB 비밀번호 환경변수였다는 것을 찾았어요. → 🖥️ 37
💼 실무·코딩테스트에서는실무에서는 "동작은 그대로, 구조만 바꾸는" 리팩터링을 기능 변경과 섞지 않는 것이 기본 원칙입니다. 한 커밋에 둘이 섞이면 버그가 생겼을 때 어느 쪽 탓인지 가를 수 없고, 코드 리뷰도 어려워지죠. 04 → 05처럼 "이번에는 View만"이라고 범위를 정해 두면 비교도, 되돌리기도 쉬워집니다.
정리 작업 메모
JSTL 전환 직후 04와 05의 Controller · DAO · DTO · Filter를 컴파일해 클래스 파일이 바이트 단위로 같은 것을 확인했다. 그 뒤 05의 HkDao·DataBase에 printSqlError와 INSERT 컬럼 명시가 추가되어 지금은 두 프로젝트의 DAO가 다르다.
37
조용히 실패하는 코드 고치기 — 환경변수 · 예외 로그 · INSERT 컬럼
디버깅SQLStateSystem.getenvINSERT 컬럼 명시
한 줄 요약DAO가 catch에서 false나 빈 목록만 돌려주면 "DB 접속 실패"와 "글 0건"이 화면에서 똑같아 보인다. 오류는 작업 이름 + SQLState로 남기고, 비밀번호는 환경변수 이름으로 읽고, INSERT는 컬럼을 적는다.
쉽게 말하면스위치를 눌러도 불이 안 켜지는데 아무 경고등도 없으면 전구 탓인지 배선 탓인지 알 수 없습니다. 조용히 꺼지는 고장은 어디서 왜 꺼졌는지 기록(로그)부터 남겨야 잡힙니다. 그리고 범위를 좁힐 때는 멀쩡한 옆집 스위치도 눌러 보는 것이 빠르죠 — 옆집도 안 켜지면 우리 집 전구가 아니라 동네 전기가 문제니까요.
35일차에 05 글쓰기가 계속 error.jsp로 튕겼습니다. JSTL을 막 바꾼 참이라 JSTL을 의심하기 쉬웠지만, 같은 요청을 04에도 보내 보니 04도 똑같이 실패했어요. 둘이 공유하는 부분 — DB 접속이 원인이었습니다. 이클립스를 평소 실행기 대신 탐색기에서 켜는 바람에 STUDY_DB_PASSWORD 환경변수가 없었고, Tomcat은 이클립스의 환경을 물려받으니 접속이 전부 실패한 거죠. 그런데 DAO는 printStackTrace()만 하고 false를 돌려주어, 화면에서는 그냥 "실패"로만 보였습니다.
JAVA05 DataBase.java — 환경변수로 접속 정보 읽기 + 오류를 한 줄로 남기는 메서드
public Connection getConnection() throws SQLException {
String url = System.getenv().getOrDefault("STUDY_DB_URL", "jdbc:mariadb://127.0.0.1:3306/hk");
String user = System.getenv().getOrDefault("STUDY_DB_USER", "study");
// getenv("이름")은 '값'이 아니라 '환경변수 이름'으로 찾는다
// String password = System.getenv("내가쓰는비밀번호"); // X : 그런 이름의 환경변수는 없다 → 항상 null
String password = System.getenv("STUDY_DB_PASSWORD"); // ← O : 환경변수 '이름'을 적는다
return DriverManager.getConnection(url, user, password);
}
// 어떤 작업(step)에서 무슨 오류인지 한 줄로 남긴다
// 예) SQLState=28000 → 계정/비밀번호 문제, 42S02 → 테이블 없음
protected void printSqlError(String step, SQLException e) {
System.err.println("[DB오류] " + step
+ " | SQLState=" + e.getSQLState() // ← 오류 종류(표준 코드)
+ " | 오류코드=" + e.getErrorCode() // ← DB 제품별 번호
+ " | " + e.getMessage());
e.printStackTrace();
}
JAVAHkDao.java — 04(고치기 전)와 05(고친 뒤)
// ===== getAllList() 의 catch =====
} catch (SQLException e) {
e.printStackTrace(); // 04 : 원인이 스택에 묻힌다
}
} catch (SQLException e) {
// 조회 실패와 "글이 0건"은 화면상 똑같이 빈 목록으로 보이기 때문에
// 콘솔에 반드시 원인을 남겨야 한다
printSqlError("글목록 조회(getAllList)", e); // ← 05
}
// ===== insertBoard() 의 SQL =====
String sql = " INSERT INTO HKBOARD " // 04 : 컬럼 목록 생략
+ " VALUES(NULL,?,?,?,SYSDATE()) ";
String sql = " INSERT INTO HKBOARD(ID, TITLE, CONTENT, REGDATE) " // ← 05 : 컬럼 명시
+ " VALUES(?,?,?,SYSDATE()) "; // SEQ 는 AUTO_INCREMENT 라 DB 에 맡긴다
37번 정리 — 핵심 정리
System.getenv("이름")은 운영체제에 등록된 환경변수 이름으로 값을 찾아온다. 비밀번호를 코드에 적지 않고 저장소 밖에 두는 방법이며, 그 이름의 변수가 없으면 null이다.
catch에서 조용히 false를 돌려주면 화면은 같은 얼굴을 한다. 작업 이름과 SQLState(예: 28000 계정·비밀번호 문제, 42S02 테이블 없음)를 남겨 두면 원인이 바로 갈린다.
getAllList()는 실패해도 빈 목록을 돌려주므로, 05 목록은 DB가 죽어도 "작성된 글이 없습니다"를 보여 준다. → 🖥️ 35
INSERT INTO HKBOARD VALUES(NULL,?,?,?,SYSDATE())처럼 컬럼 목록을 생략하면 테이블 컬럼이 바뀌는 순간 깨진다. 컬럼을 명시하고 AUTO_INCREMENT인 SEQ는 DB에 맡긴다.
범위를 좁히는 요령: 바뀐 쪽과 안 바뀐 쪽에 같은 요청을 보내 본다. 둘 다 실패하면 새로 고친 부분이 아니라 공통 부분(DB 접속 등)이 원인이다.
Tomcat은 자기를 띄운 프로그램(이클립스)의 환경변수를 물려받는다 — 실행하는 방법이 바뀌면 환경도 바뀐다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
System.getenv("1234")처럼 괄호 안에 비밀번호 값을 직접 적으면 왜 접속이 실패하나?
getenv는 "1234라는 이름의 환경변수"를 찾기 때문입니다. 그런 이름은 없으니 언제나 null이 돌아오고, 비밀번호 없이 접속을 시도하게 되어 계정·비밀번호 오류로 거절당합니다. 값을 적고 싶었다면 getenv를 쓸 이유가 없고, 값을 저장소 밖에 두려고 쓰는 것이니 괄호 안에는 이름만 적어야 합니다.
04의 INSERT처럼 컬럼 목록을 생략한 채로 HKBOARD에 새 컬럼(예: 조회수)이 추가되면?
INSERT가 실패합니다. 컬럼 목록을 생략하면 테이블의 모든 컬럼에 순서대로 값을 줘야 하는데, VALUES의 값은 5개 그대로라 개수가 맞지 않거든요. 컬럼을 명시한 05의 SQL은 새 컬럼에 기본값이 있거나 NULL을 허용한다면 그대로 동작합니다. 그리고 이 실패도 catch에서 false가 되어 화면에는 error.jsp 한 장으로만 보인다는 점 — 그래서 로그가 필요합니다.
💼 실무·코딩테스트에서는실무에서 가장 무서운 버그는 에러가 나는 버그가 아니라 조용히 틀리는 버그입니다. 그래서 예외를 삼키지 않기, 삼켜야 한다면 무엇을 하다가 왜 실패했는지 로그로 남기기가 기본 규칙이에요. 비밀번호·API 키를 환경변수나 설정 파일로 빼고 저장소에 올리지 않는 것도 마찬가지로 실무 필수입니다. 면접에서 "디버깅을 어떻게 하나"를 물으면 "바뀐 쪽과 안 바뀐 쪽을 같은 입력으로 비교해 범위를 좁혔다"는 이 경험이 좋은 답이 됩니다.
정리 작업 메모
05 글쓰기가 계속 error.jsp로 튕겨서, 같은 요청을 04에도 보내 보니 똑같이 실패했다 — 원인은 JSTL이 아니라 공통인 DB 접속이었다.
이클립스를 탐색기에서 직접 실행하면 평소 실행기가 넣어 주던 STUDY_DB_PASSWORD가 프로세스에 없다. Tomcat은 이클립스의 환경을 물려받으므로 접속이 전부 실패했다.
38
스프링 첫 프로젝트 — 내가 하던 일을 누가 대신하는가
Spring 6.1Mavenpom.xmlIoC
한 줄 요약05까지 직접 손으로 하던 세 가지 — jar 챙기기 · 요청 분기 코드 짜기 · 객체 만들기 — 를 각각 Maven · DispatcherServlet · 스프링 컨테이너가 대신 맡는다.
쉽게 말하면04·05에서는 내가 주방장이자 웨이터였습니다. 재료(jar)를 직접 사다 넣고, 주문서(URL)를 읽어 어느 요리를 할지 직접 갈라내고, 그릇(객체)도 직접 꺼냈죠. 스프링은 주방 설비가 이미 갖춰진 가게입니다. 재료는 목록만 적어 주면 들어오고, 주문 분배는 정해진 직원이 하고, 그릇은 달라고 하면 나옵니다. 내가 쓰는 건 "이 주문에는 이 요리"라는 메뉴판 한 장뿐이에요.
① 라이브러리 — WEB-INF/lib 복사에서 pom.xml 선언으로
05에서는 JSTL jar 2개를 직접 내려받아 WEB-INF/lib에 넣었습니다. 06은 Maven 프로젝트라 pom.xml에 무엇이 필요한지만 적고, 실제 jar는 Maven이 받아 옵니다. 게다가 그 jar가 또 필요로 하는 것(의존성의 의존성)까지 따라옵니다 — spring-webmvc 하나를 적으면 spring-core·spring-beans·spring-web이 함께 들어오죠.
② 요청 분기 — 내가 만든 Controller 서블릿에서 DispatcherServlet으로
04·05의 BoardController는 내가 작성한 서블릿이었습니다. getRequestURI()로 URL을 읽고 if로 갈라 DAO를 부르고 forward 했죠. 스프링에서는 그 자리에 이미 만들어진 DispatcherServlet이 들어섭니다. 나는 분기 코드를 쓰지 않고 @RequestMapping으로 "이 URL은 이 메서드"라고 표시만 합니다. → 🖥️ 39. 요청이 지나는 길
③ 객체 생성 — new 에서 컨테이너로 (IoC)
지금까지는 필요한 객체를 new로 직접 만들었습니다(HkDao dao = new HkDao();). 스프링에서는 컨테이너가 객체를 만들어 보관하고, 필요할 때 꺼내 줍니다. @Controller가 붙은 클래스를 component-scan이 찾아 컨테이너에 등록하죠. 제어의 주도권이 내 코드에서 프레임워크로 넘어간다고 해서 IoC(제어의 역전)라고 부릅니다. — 05의 "WAS가 내 코드를 대신 실행한다"와 같은 이야기가 한 겹 더 안쪽에서 반복되는 것입니다.
XML06 pom.xml — 무엇이 필요한지만 적는다
<packaging>war</packaging> <!-- ← 톰캣에 올리는 웹 애플리케이션 -->
<properties>
<java-version>21</java-version>
<org.springframework-version>6.1.13</org.springframework-version> <!-- ← 버전을 한곳에 -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<version>${org.springframework-version}</version> <!-- ← 위의 값을 가져다 쓴다 -->
</dependency>
<!-- Servlet / JSP / JSTL (jakarta 네임스페이스, Tomcat 10.1용) -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope> <!-- ← 톰캣이 이미 갖고 있으므로 war에 넣지 않는다 -->
</dependency>
<!-- DB 설정관련 라이브러리 : mariadb-java-client · mybatis · mybatis-spring
· spring-jdbc · spring-orm · commons-dbcp2 (36일차에는 아직 쓰지 않았다) -->
TEXT05 → 06 — 내가 하던 일 중 무엇이 사라졌나
하던 일 05까지 06부터
─────────────────────────────────────────────────────────────────────────────
라이브러리 jar 를 받아 WEB-INF/lib 에 복사 pom.xml 에 선언만
요청 분기 BoardController + getRequestURI + if DispatcherServlet + @RequestMapping
화면 이동 getRequestDispatcher("boardlist.jsp") return "home" (경로는 ViewResolver)
객체 생성 new HkDao() 컨테이너가 만들어 보관
한글 인코딩 직접 만든 EncodeFilter CharacterEncodingFilter 등록
→ DAO·DTO 는 아직 그대로다. 바뀐 것은 "요청을 받아 화면까지 넘기는" 구간뿐이다.
38번 정리 — 핵심 정리
06은 Maven 프로젝트다 — WEB-INF/lib에 jar를 넣지 않고 pom.xml에 선언한다.
${...}로 버전을 한곳에 모아 두면 스프링 버전을 올릴 때 properties 한 줄만 고치면 된다.
<scope>provided</scope>는 "컴파일할 때는 필요하지만 서버가 이미 갖고 있으니 war에는 넣지 말라"는 뜻이다. 서블릿·JSP API가 여기 해당한다. 넣으면 톰캣 것과 충돌한다.
Tomcat 10.1은 jakarta 네임스페이스라 jakarta.servlet-api를 쓴다 — javax 버전을 쓰면 안 된다.
패키징이 war다 — 톰캣에 올리는 웹 애플리케이션이라는 뜻이다.
36일차 pom에는 MyBatis · spring-jdbc · commons-dbcp2 · MariaDB 드라이버가 미리 들어 있었다. 아직 쓰지 않았지만 다음 진도가 DB 연동이라는 예고였다. → 🖥️ 43. DBCP와 SqlSessionTemplate
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
서블릿 API jar가 war 안(WEB-INF/lib)에 함께 들어갑니다. 톰캣은 이미 자기 서블릿 API를 갖고 있으니 같은 클래스가 두 군데 있게 되고, 버전이 다르면 어느 쪽을 읽느냐에 따라 충돌이 납니다. "컴파일에는 필요하지만 실행 환경이 이미 갖고 있는 것"에 provided를 붙이는 이유입니다.
05의 new HkDao()와 06의 @Controller + component-scan은 "누가 객체를 만드는가"에서 어떻게 다른가?
05는 내 코드가 요청이 올 때마다 new HkDao()로 직접 만들었습니다. 06은 스프링 컨테이너가 시작할 때 component-scan으로 @Controller 클래스를 찾아 한 번 만들어 보관하고, 요청이 오면 그 객체의 메서드를 불러 줍니다. 내 코드 어디에도 new HomeController()가 없다는 것이 IoC의 모습이에요.
💼 실무·코딩테스트에서는"스프링을 왜 쓰나"는 면접 단골입니다. "편해서"보다 "객체의 생성·연결을 내가 아니라 컨테이너가 하기 때문에, 바꿔 끼우기 쉽고 테스트하기 쉬워진다"로 답하는 게 좋습니다. 05까지 직접 만들어 본 Controller·DAO가 있다는 것이 여기서 강점이 됩니다 — 프레임워크가 무엇을 대신해 주는지 비교할 대상이 있으니까요. 실무 신규 프로젝트는 대개 스프링 부트로 시작하지만, 부트도 결국 이 설정들을 자동으로 해 줄 뿐이라 06의 구조를 알면 부트가 무엇을 숨기는지도 보입니다.
39
요청이 스프링 안에서 지나는 길 — web.xml · DispatcherServlet · ViewResolver
한 줄 요약*.do 요청을 DispatcherServlet이 받아 @RequestMapping이 붙은 메서드를 찾고, 메서드가 돌려준 이름에 ViewResolver가 앞뒤를 붙여 JSP 경로를 만든다. 설정은 root-context(화면과 무관한 것)와 servlet-context(화면에 딸린 것) 두 벌로 나뉜다.
쉽게 말하면대표 전화(DispatcherServlet)가 *.do 전화를 모두 받아 담당자(@RequestMapping 메서드)에게 돌리고, 담당자가 "home"이라고만 말하면 비서(ViewResolver)가 앞뒤 주소를 붙여 화면을 찾아 줍니다. 회사 전체가 쓰는 금고·장부(DB)는 본사 설정(root-context)에, 전화 응대 규칙은 안내 데스크 설정(servlet-context)에 따로 적어 둡니다.
web.xml에 설정이 두 벌인 이유
root-context.xml은 ContextLoaderListener가 읽고, servlet-context.xml은 DispatcherServlet이 읽습니다. 나누는 기준은 "화면과 상관있는가"예요. Controller·ViewResolver처럼 웹 화면에 딸린 것은 servlet-context에, DB·서비스처럼 화면과 무관하게 애플리케이션 전체가 쓰는 것은 root-context에 둡니다. 36일차에는 root-context.xml이 <beans>만 있는 빈 파일이었고, 다음 날(9월 23일) DataSource·SqlSessionFactory·SqlSessionTemplate이 바로 이 파일에 들어왔습니다. → 🖥️ 43
*.do 매핑 — 04의 *.board와 같은 발상
04에서 *.board로 끝나는 요청을 내 Controller 서블릿에 몰아줬던 것과 똑같은 방식입니다(→ 🖥️ 31). 다만 받는 쪽이 내 클래스가 아니라 스프링의 DispatcherServlet이라는 점만 다르죠. *.do로 끝나지 않는 요청(index.jsp 같은 것)은 애초에 스프링을 거치지 않습니다.
ViewResolver — "이름"만 돌려주면 경로는 붙여 준다
04·05에서는 request.getRequestDispatcher("boardlist.jsp").forward(...)처럼 경로를 직접 적었습니다. 스프링에서는 메서드가 "home"이라는 이름만 돌려주면, InternalResourceViewResolver가 prefix + 이름 + suffix를 조립해 /WEB-INF/views/home.jsp로 forward 합니다. 화면 폴더 구조가 바뀌어도 Controller는 그대로인 것이 이 구조의 이점입니다.
한글 깨짐 — 직접 만들던 EncodeFilter가 이미 들어 있다
04·05에서 직접 작성했던 EncodeFilter(→ 🖥️ 30)와 같은 일을 하는 CharacterEncodingFilter가 스프링에 들어 있습니다. web.xml에 등록만 하면 되죠. forceEncoding=true는 요청뿐 아니라 응답 인코딩까지 강제한다는 뜻입니다.
요청 한 번의 길: /home.do(브라우저) → *.do 매핑 → DispatcherServlet(스프링 제공) → @RequestMapping → home()이 "home" 반환 → ViewResolver → /WEB-INF/views/home.jsp(prefix + 이름 + suffix)
XML06 web.xml — 설정 두 벌과 *.do 매핑
<!-- ① 전역 설정: 리스너가 읽는다 (DB 등 화면과 무관한 것) -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/root-context.xml</param-value>
</context-param>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<!-- ② 화면 설정: DispatcherServlet이 읽는다 (Controller · ViewResolver) -->
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/appServlet/servlet-context.xml</param-value>
</init-param>
<load-on-startup>1</load-on-startup> <!-- ← 첫 요청을 기다리지 않고 시작할 때 미리 만든다 -->
</servlet>
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>*.do</url-pattern> <!-- ← .do 로 끝나는 요청만 스프링으로 -->
</servlet-mapping>
<!-- ③ 인코딩 필터: 직접 만들던 EncodeFilter 대신 -->
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param><param-name>encoding</param-name><param-value>UTF-8</param-value></init-param>
<init-param><param-name>forceEncoding</param-name><param-value>true</param-value></init-param>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
XML06 servlet-context.xml — 네 줄이 하는 일
<mvc:annotation-driven/> <!-- ← @Controller 방식을 쓰겠다 -->
<mvc:resources location="/resources/" mapping="/**"></mvc:resources> <!-- 정적 파일 경로 -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
<property name="prefix" value="/WEB-INF/views/" /> <!-- ← 앞에 붙일 것 -->
<property name="suffix" value=".jsp" /> <!-- ← 뒤에 붙일 것 -->
</bean>
<context:component-scan base-package="com.hk.board" /> <!-- ← 이 패키지에서 @Controller를 찾아 등록 -->
JAVA06 HomeController.java — 분기 코드가 사라졌다
@Controller // ← "컨테이너야, 이 클래스를 등록해"
public class HomeController {
@RequestMapping(value = "/home.do", method = RequestMethod.GET) // ← 이 URL은 이 메서드
public String home() {
// 페이지 이름만 작성한다
// --> WEB-INF/views + home + .jsp : ViewResolver가 해 준다
return "home";
}
}
39번 정리 — 핵심 정리
@Controller는 "컨테이너야 이 클래스를 등록해"라는 표시이고, 실제로 찾아 등록하는 것은 component-scan이다. 둘 중 하나만 있으면 동작하지 않는다.
메서드가 돌려주는 것은 경로가 아니라 이름이다. 경로는 ViewResolver의 prefix·suffix가 만든다.
/WEB-INF/ 아래는 브라우저가 직접 열 수 없다. 그래서 JSP를 그 안에 두면 반드시 Controller를 거치게 강제할 수 있다 — 04·05에서 JSP가 밖에 있어 직접 호출이 가능했던 것과 달라진 점이다. → 🖥️ 02. WEB-INF
root-context = 화면과 무관한 것(DB·서비스), servlet-context = 화면에 딸린 것(Controller·ViewResolver). 각각 리스너와 DispatcherServlet이 읽는다.
load-on-startup은 첫 요청 때 초기화하느라 느려지는 것을 막는다. 설정 오류도 배포 시점에 바로 드러난다.
*.do로 끝나지 않는 요청은 DispatcherServlet을 거치지 않는다 — 링크 주소의 확장자가 곧 "스프링을 탈지 말지"다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
home()이 "home" 대신 "/WEB-INF/views/home.jsp"를 돌려주면 어떻게 되나?
화면을 찾지 못합니다(404). ViewResolver는 돌려받은 값이 이름이든 경로든 상관없이 무조건 앞뒤를 붙여서/WEB-INF/views//WEB-INF/views/home.jsp.jsp라는 없는 경로를 만들거든요. "경로는 ViewResolver 몫, 나는 이름만"이라는 약속을 기억해 두면 이런 실수를 피할 수 있습니다.
HomeController를 com.hk.test 패키지에 만들면 /home.do는 어떻게 되나?
404가 납니다.component-scan base-package="com.hk.board"는 com.hk.board와 그 아래 패키지만 뒤지므로, com.hk.test의 클래스는 @Controller가 붙어 있어도 등록되지 않아요. DispatcherServlet 입장에서는 /home.do를 맡을 메서드가 없는 셈입니다.
브라우저 주소창에 /06_spring_template/WEB-INF/views/home.jsp를 직접 치면?
열리지 않습니다(404).WEB-INF 아래는 톰캣이 외부 요청에는 절대 내주지 않는 영역이에요. 서버 안에서 하는 forward로만 들어갈 수 있으니, home.jsp를 보려면 반드시 /home.do → Controller를 거쳐야 합니다.
💼 실무·코딩테스트에서는"DispatcherServlet의 요청 처리 흐름을 설명하라"는 스프링 면접의 단골 질문입니다. "요청 → DispatcherServlet → 핸들러(Controller) 찾기 → 실행 → 뷰 이름 → ViewResolver → 화면"의 순서를 06의 실제 파일로 짚어 말할 수 있으면 충분해요. 요즘 실무의 스프링 부트는 web.xml 없이 이 설정을 자동으로 해 주지만, 안쪽의 흐름은 똑같습니다. root/servlet 두 설정의 구분은 08에서 트랜잭션을 걸기 위해 스캔 범위를 나눌 때 다시 중요해집니다.
40
스프링 MVC 설정에서 자주 하는 실수
뷰 이름404mvc:resourcesmetadata-complete
한 줄 요약스프링은 "이름"과 "설정"으로 연결되는 부분이 많아서, 틀려도 컴파일은 통과하고 실행해 봐야 드러나는 실수가 많다. 06 프로젝트에 실제로 있는 세 가지 — 뷰 이름 불일치, 넓은 mvc:resources, metadata-complete — 를 짚는다.
쉽게 말하면안내판(뷰 이름)과 실제 방(JSP 파일)의 이름이 어긋나도 건물 설계도 검사(컴파일)는 통과합니다. 설계도에는 "main이라는 방으로 안내"라고만 적혀 있을 뿐, 그 방이 실제로 지어졌는지는 검사하지 않거든요. 손님이 직접 그 방을 찾아가 봐야(실행) "그런 방 없음(404)"이 나옵니다.
뷰 이름 ↔ 파일 이름 불일치 → 실행 시점의 404. Controller가 return "main";을 돌려주면 ViewResolver가 /WEB-INF/views/main.jsp라는 경로를 만든다. 그 파일이 없으면 컴파일은 멀쩡하고, 주소를 열어 봐야 404가 난다. 오타("hom")도 똑같이 숨는다.
mvc:resources의 mapping을 /**로 넓게 잡기. 보통은 <mvc:resources mapping="/resources/**" location="/resources/" />처럼 정적 파일 폴더만 잡는다. /**는 DispatcherServlet이 받은 모든 요청을 정적 자원 후보로 보겠다는 뜻이라, 지금은 Controller 매핑이 먼저 조회되어 동작해도 의도가 흐려지고 매핑이 바뀌면 엉뚱한 파일이 응답될 수 있다.
web.xml의 metadata-complete="true"는 "애너테이션(@WebServlet·@WebFilter 등)을 스캔하지 말고 이 파일만 보라"는 뜻이다. 이 상태에서 @WebServlet을 붙인 클래스를 추가하면 등록되지 않아 404가 난다. 스프링에서는 등록을 DispatcherServlet 하나에 몰아 두므로 보통 문제가 없다.
JAVA06 HomeController.java — 이름은 돌려주지만 그 파일이 없다
@RequestMapping(value = "/home.do", method = RequestMethod.GET)
public String home() {
return "home"; // → /WEB-INF/views/home.jsp (있다)
}
@RequestMapping(value = "/main.do", method = RequestMethod.GET)
public String main() {
return "main"; // ← /WEB-INF/views/main.jsp (없다 — 그래도 컴파일은 통과)
}
/* src/main/webapp/WEB-INF/views/
home.jsp ← 이 파일 하나뿐 */
XML06 web.xml · servlet-context.xml — 문제의 두 줄
<!-- web.xml -->
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
...
version="6.0"
metadata-complete="true"> <!-- ← @WebServlet·@WebFilter 를 찾지 않는다 -->
<!-- servlet-context.xml -->
<mvc:resources location="/resources/" mapping="/**"></mvc:resources>
<!-- ← 보통은 mapping="/resources/**" 처럼 좁게 -->
뷰 이름이 틀리면 Controller까지는 정상 실행되고, JSP를 찾는 마지막 단계에서 404가 난다. "Controller가 안 불렸다"와 구별해야 한다.
mvc:resources는 정적 파일 폴더만 좁게 잡는 것이 의도가 분명하다.
metadata-complete="true"면 애너테이션으로 등록한 서블릿·필터가 무시된다 — 03~05에서 쓰던 @WebServlet 방식이 06에서는 통하지 않는다.
링크 주소가 *.do로 끝나지 않으면 DispatcherServlet에 닿지 않는다 — 07에서 .board와 .do가 섞여 실제로 겪은 문제다. → 🖥️ 46. 오류를 단계별로 좁히기
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
/main.do를 열면 404가 난다. 이때 main() 메서드는 실행된 걸까, 안 된 걸까?
실행됐습니다.@RequestMapping("/main.do")가 있으니 DispatcherServlet은 main()을 찾아 부르고, "main"을 돌려받아요. 그다음 ViewResolver가 만든 /WEB-INF/views/main.jsp로 forward 하려는데 파일이 없어서 404가 나는 것입니다. 메서드 안에 System.out.println을 하나 넣어 보면 콘솔에 찍히는 것으로 확인할 수 있어요. 해결은 main.jsp를 만들거나 반환값을 있는 이름으로 바꾸는 것입니다.
06 프로젝트에 03의 HelloServlet(@WebServlet("/HelloServlet.do"))을 그대로 복사해 넣으면?
/HelloServlet.do가 404입니다. 06의 web.xml에 metadata-complete="true"가 있어서 톰캣이 클래스의 애너테이션을 아예 읽지 않기 때문이에요. 등록되지 않은 이 주소는 *.do 매핑을 따라 DispatcherServlet으로 가는데, 거기에는 이 URL을 맡은 @RequestMapping이 없으니 404가 납니다. 꼭 서블릿을 추가해야 한다면 web.xml에 <servlet>으로 등록하거나 이 속성을 지워야 합니다. 스프링에서는 보통 서블릿을 따로 만들지 않고 Controller 메서드를 추가합니다.
💼 실무·코딩테스트에서는"이름으로 화면을 찾는" 구조는 오타가 실행 시점까지 숨는다는 대가를 치릅니다. 그래서 실무에서는 화면 이름을 상수로 모아 두거나, 최소한 컨트롤러마다 한 번은 실제로 열어 보는 것으로 대응합니다. 더 나아가면 MockMvc 같은 테스트로 "이 URL이 이 뷰 이름을 돌려주는가"를 자동으로 확인하기도 해요. 404를 만났을 때 "Controller가 안 불렸나, 화면을 못 찾았나"를 먼저 가르는 습관이 디버깅 시간을 크게 줄여 줍니다.
정리 작업 메모
2026-09-22 기준 06 프로젝트 상태: HomeController의 main()은 "main"을 돌려주지만 /WEB-INF/views/에는 home.jsp만 있어 main.do는 화면을 찾지 못한다. root-context.xml은 <beans> 껍데기만 있고, pom.xml에는 MyBatis·spring-jdbc·DBCP·MariaDB 드라이버가 이미 들어 있다. mvc:resources mapping은 /**, web.xml은 metadata-complete="true"이며 템플릿 설정은 수업 진도 그대로 두었다.
확인함 — 이클립스 m2e가 pom.xml의 의존성을 내려받아 HomeController를 컴파일했다(target/classes에 .class 생성). 소스 7개 파일은 워크스페이스 원본과 동일하다.
확인하지 않음 — 톰캣에 배포해 /home.do를 브라우저로 열어 본 기록은 이 정리에 없다. "화면이 뜬다"는 설명은 설정을 읽고 따진 결과다.
41
게시판 목록이 화면에 나오기까지 — Controller · Service · DAO · JSP
Spring MVC계층 구조Model뷰 이름
한 줄 요약GET /boardlist.do 요청 하나가 Controller → Service → DAO → MyBatis → DB를 거쳐 List<HkDto>가 되어 돌아오고, Controller가 그것을 Model에 담아 뷰 이름 "boardlist"를 돌려주면 JSP가 c:forEach로 표를 그린다.
쉽게 말하면식당으로 치면 Controller는 홀 직원(주문을 받고 어느 테이블에 낼지 정함), Service는 주방장(요리 순서를 정함), DAO는 창고 담당(재료를 꺼내 옴)입니다. 지금은 주방장이 창고 담당에게 "그대로 꺼내 와"라고만 전하는 수준이라 Service가 하는 일이 거의 없어 보이지만, 나중에 "두 재료를 함께 꺼내야 하는" 일이 생기면 그 판단을 둘 자리를 미리 만들어 둔 것입니다.
① 06에서 07로 — 고정 화면에서 진짜 데이터로
06 스프링 템플릿에서는 Controller가 고정된 인사 화면만 열었습니다. 07에서는 같은 구조에 Service·DAO·MyBatis를 붙여 실제 HKBOARD 테이블의 글 목록을 읽습니다. 요청의 길은 web.xml의 *.do → DispatcherServlet → BoardController.boardList()로, 앞 카드의 흐름 그대로입니다. → 🖥️ 39. 요청이 스프링 안에서 지나는 길
② return "boardlist"는 주소도 파일 경로도 아니다
Controller가 돌려주는 문자열은 뷰 이름입니다. servlet-context.xml의 ViewResolver가 앞에 /WEB-INF/views/, 뒤에 .jsp를 붙여 /WEB-INF/views/boardlist.jsp를 찾고, 서버 안에서 forward합니다. 그래서 브라우저 주소창은 끝까지 boardlist.do로 남습니다. 요청 URL(boardlist.do)과 뷰 이름(boardlist)이 비슷해 보여도 서로 다른 것이라는 점이 핵심입니다.
③ Model은 Controller와 JSP 사이의 전달 상자
model.addAttribute("list", list)로 넣은 값은 forward된 JSP에서 request 속성처럼${list}로 꺼낼 수 있습니다. MVC2에서 request.setAttribute(...)를 직접 부르던 일을 스프링이 Model로 대신해 주는 것입니다.
JAVABoardController.java — 목록 요청 처리
//메서드별로 url맵핑을 함
@RequestMapping(value="/boardlist.do", method = RequestMethod.GET )
public String boardList(Model model) {
List<HkDto> list=hkService.getAllList(); // ← Service → DAO → MyBatis → DB
model.addAttribute("list", list); // ← JSP 에서 ${list} 로 꺼낼 이름
return "boardlist"; //forward 방식 // ← 뷰 이름 → /WEB-INF/views/boardlist.jsp
//return "redirect:boardlist.do"; //리다이렉트 방식
}
JSPboardlist.jsp — Model 값을 표로 그리기
<c:choose>
<c:when test="${empty list}"> <%-- ← 글이 없을 때 --%>
<tr><td colspan="5">--작성된 글이 없습니다.--</td></tr>
</c:when>
<c:otherwise>
<c:forEach items="${list}" var="dto"> <%-- ← Model 의 "list" --%>
<tr>
<td>${dto.seq}</td>
<td>${dto.id}</td>
<td><a href="boardDetail.do?seq=${dto.seq}">${dto.title}</a></td>
</tr>
</c:forEach>
</c:otherwise>
</c:choose>
41번 정리 — 핵심 정리
흐름: 요청 → Controller → Service → DAO → MyBatis → DB, 돌아올 때는 List → Model → 뷰 이름 → JSP.
요청 URL은 boardlist.do, 뷰 이름은 boardlist — 다른 것이다.
ViewResolver가 /WEB-INF/views/ + 뷰 이름 + .jsp로 경로를 조립해 forward한다.
model.addAttribute("이름", 값)의 첫 번째 문자열이 JSP에서 쓸 이름이다.
지금의 Service는 DAO를 그대로 부르지만, 여러 DAO 호출을 묶는 자리(트랜잭션 등)로 쓰인다. → 🖥️ 59. 트랜잭션
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
model.addAttribute("list", list)의 두 list는 같은 뜻인가?
아닙니다. 첫 번째 "list"는 JSP에서 찾을 이름표(문자열)이고, 두 번째 list는 실제 조회 결과가 든 자바 변수입니다. 이름표를 "boards"로 바꾸면 JSP도 ${boards}로 맞춰야 하고, 변수 이름은 JSP와 아무 관계가 없습니다.
return "boardlist" 대신 return "boardlist.jsp"라고 쓰면 어떻게 되나?
ViewResolver가 뒤에 .jsp를 또 붙여 /WEB-INF/views/boardlist.jsp.jsp를 찾게 되고, 그런 파일은 없으니 404가 납니다. Controller는 확장자와 폴더를 뺀 이름만 돌려주고, 나머지는 설정이 채운다는 약속을 지켜야 합니다.
HKBOARD에 글이 하나도 없으면 화면은 어떻게 나오나?
MyBatis의 selectList는 결과가 없으면 null이 아니라 빈 List를 돌려줍니다. JSP의 ${empty list}는 빈 List도 참으로 보므로 "--작성된 글이 없습니다.--" 줄이 나옵니다.
💼 실무·코딩테스트에서는실무 스프링 프로젝트의 대부분이 Controller · Service · Repository(DAO) 세 층으로 나뉩니다. 면접에서 "Service 계층은 왜 두나요?"라는 질문이 자주 나오는데, 핵심 답은 "Controller는 요청·응답만, 업무 규칙과 트랜잭션은 Service가 맡아서 웹이 아닌 곳(배치, 테스트)에서도 같은 로직을 다시 쓸 수 있게"입니다. 지금처럼 그대로 넘기기만 하는 Service도 흔한데, 나중에 로직이 생겨도 Controller를 건드리지 않게 해 주는 자리라는 점을 기억해 두세요.
42
인터페이스와 의존성 주입 — new 하지 않고 받아 쓰기
DI@Autowiredcomponent-scan인터페이스컨텍스트
한 줄 요약Controller·Service·DAO는 서로를 직접 new 하지 않는다. 필요한 타입(인터페이스)만 선언하고 @Autowired를 붙이면, 스프링이 component-scan으로 등록해 둔 객체(빈) 중 맞는 것을 넣어 준다.
쉽게 말하면식당 홀 직원(Controller)은 주방장을 직접 고용하지 않습니다. "주방장 역할을 할 사람이 필요하다"고만 적어 두면 가게 주인(스프링 컨테이너)이 미리 뽑아 둔 사람을 배치해 주죠. 주방장이 바뀌어도 홀 직원은 "주방장에게 주문을 넘긴다"는 일만 하면 되니 일하는 방식이 바뀌지 않습니다.
① 역할 표시 애너테이션과 component-scan
@Controller, @Service, @Repository는 "이 클래스는 이런 역할의 부품"이라는 표시입니다. servlet-context.xml의 <context:component-scan base-package="com.hk.board"/>가 이 패키지 아래를 훑어 표시가 붙은 클래스를 객체로 만들어 컨테이너에 등록합니다. 단, @Repository를 붙였다고 DAO 메서드가 저절로 구현되는 것은 아닙니다 — 메서드 몸통은 HkDao에 직접 썼습니다.
② 왜 인터페이스 + 주입인가
Controller는 IHKService라는 약속(인터페이스)만 알고, 실제로 들어오는 것은 그 약속을 구현한 HkService 객체입니다. 그래서 HkService를 다른 구현(예: 테스트용 가짜 서비스, 다른 DB용 DAO)으로 바꿔 끼워도 Controller 코드는 한 줄도 고치지 않습니다. 직접 new HkService()를 쓰면 그 클래스에 묶여 바꾸기 어렵습니다. @Autowired는 기본적으로 타입을 기준으로 후보를 찾고, 후보가 없거나 여러 개라 고를 수 없으면 서버가 시작될 때 오류가 납니다.
③ 부모(root)·자식(servlet) 컨텍스트 — 07은 어떻게 나뉘었나
스프링 MVC 앱에는 빈 상자(컨테이너)가 둘 있습니다. root-context.xml이 만드는 부모 컨텍스트에는 보통 DB 연결처럼 앱 전체가 함께 쓰는 빈을, servlet-context.xml이 만드는 DispatcherServlet의 자식 컨텍스트에는 Controller·ViewResolver 같은 웹 빈을 둡니다. 자식은 부모의 빈을 쓸 수 있지만 부모는 자식의 빈을 보지 못합니다(자세히 → 🖥️ 59. 컨텍스트 나누기). 07 코드의 servlet-context.xml은 com.hk.board 전체를 스캔하므로 Controller·Service·DAO가 모두 자식 컨텍스트에 있고, 부모 root-context의 DB 빈(sqlSessionTemplate)을 이용합니다. 역할별로 스캔 범위를 나누는 구조와 혼동하지 않도록 합니다.
JAVABoardController · HkService · HkDao — 세 층이 연결되는 자리
@Controller
public class BoardController {
@Autowired
private IHKService hkService; // ← 선언은 인터페이스, 들어오는 건 HkService 객체
}
// controller ---> service ---> dao
@Service
public class HkService implements IHKService {
// @Autowired: spring이 타입으로 구별해서 주입해준다.
@Autowired
private IHkDao hkDao; // ← IHkDao 를 구현한 빈은 HkDao 하나뿐
}
@Repository
public class HkDao implements IHkDao {
@Autowired
private SqlSessionTemplate sqlSession; // ← 부모(root-context)에 등록된 빈
}
07은 com.hk.board 전체를 servlet-context에서 스캔 → 세 층 모두 자식 컨텍스트, DB 빈만 부모.
이 자료는 수업의 필드 주입 방식을 그대로 기록한다. 생성자 주입은 🖥️ 61에서 다룬다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
IHKService는 인터페이스라 몸통이 없는데, 어떻게 hkService.getAllList()가 실행되나?
변수에 들어 있는 것이 인터페이스가 아니라 그것을 구현한 HkService 객체이기 때문입니다. 스프링이 IHKService 타입에 맞는 빈을 찾아 넣어 주었고, 호출하면 실제 객체의 메서드가 실행됩니다. 변수의 선언 타입과 실제 객체의 타입을 구분하는 것 — 다형성 그대로입니다.
HkService에서 @Service를 지우면 어떻게 되나?
component-scan이 HkService를 빈으로 등록하지 않습니다. 그러면 Controller의 @Autowired IHKService에 넣을 후보가 하나도 없으니, 요청을 받기도 전에 서버 시작 단계에서 "주입할 빈이 없다"는 오류로 앱이 뜨지 않습니다. 실행 중 NullPointerException이 아니라 시작할 때 바로 알려 준다는 점이 장점입니다.
💼 실무·코딩테스트에서는DI(의존성 주입)와 IoC(제어의 역전)는 스프링 면접 1순위 질문입니다. "객체를 내가 만드는 게 아니라 컨테이너가 만들어 넣어 준다"를 지금 코드의 @Autowired IHKService로 설명할 수 있으면 충분합니다. 실무에서는 필드 주입보다 생성자 주입을 권장합니다 — 의존 대상이 final로 고정되고, 테스트에서 new HkService(가짜DAO)처럼 직접 넣어 볼 수 있기 때문입니다.
43
DBCP와 SqlSessionTemplate 연결 — root-context.xml의 세 빈
커넥션 풀DataSourceSqlSessionTemplateroot-context
한 줄 요약root-context.xml은 DataSource(커넥션 풀) → SqlSessionFactory → SqlSessionTemplate 순서로 빈을 엮어, DAO가 연결을 열고 닫는 일 없이sqlSession.selectList(...) 한 줄로 SQL을 실행하게 해 준다.
쉽게 말하면공유 자전거를 떠올려 보세요. DataSource는 자전거 거치대(연결을 미리 세워 두고 빌려줌), SqlSessionFactory는 정비소(MyBatis 설정과 SQL 목록을 읽어 탈 준비를 함), SqlSessionTemplate은 대여 창구입니다. DAO는 창구에 "이 SQL 실행해 줘"라고만 말하고, 자전거를 빌리고 반납하는 일은 창구가 알아서 합니다.
① 커넥션 풀(DBCP) — 연결을 미리 만들어 두고 빌려 쓴다
DB 연결을 새로 만드는 일은 느립니다. 그래서 BasicDataSource가 연결 몇 개를 미리 만들어 두고(풀), 요청이 오면 빌려주고 다 쓰면 닫는 대신 풀에 돌려받아 재사용합니다. JDBC 시절처럼 요청마다 DriverManager.getConnection()을 하지 않습니다. → 🖥️ 05. JDBC 6단계와 비교해 보세요.
② 세 빈의 연결 — value와 ref
① dataSource: 연결을 빌려주는 풀 → ② sqlSessionFactory: 그 dataSource(ref)와 MyBatis 설정(Configuration.xml → Mapper XML)을 읽어 SQL 실행 준비를 하는 공장 → ③ sqlSessionTemplate: 공장(constructor-arg ref)에서 세션을 받아 DAO가 selectList·insert 등을 부르는 창구. DAO는 ③만 @Autowired로 주입받습니다. value는 글자 값을, ref는 이미 등록된 다른 빈을 넘깁니다.
③ 접속 정보는 파일 밖으로 — db.properties
PropertyPlaceholderConfigurer가 classpath:properties/db.properties를 읽어 ${url} 같은 자리에 접속 정보를 채웁니다. 따라서 src/main/resources가 빌드·배포 대상에 포함되어야 합니다. 공개 저장소에는 실제 파일 대신 db.properties.example만 두고, 실제 계정·비밀번호는 로컬에서만 채웁니다.
④ defaultAutoCommit=false의 오해
defaultAutoCommit=false만으로 여러 작업이 하나의 트랜잭션이 되지는 않습니다. 이 설정에는 트랜잭션 매니저와 @Transactional이 없으므로, 트랜잭션 밖에서 실행한 MyBatis-Spring의 호출은 SQL마다 개별적으로 커밋됩니다. 여러 SQL을 하나로 묶는 방법은 🖥️ 59. 트랜잭션에서 다룹니다.
<!-- db.properties 파일을 관리하는 객체 등록 -->
<bean class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
<property name="location" value="classpath:properties/db.properties"></property>
</bean>
<bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource"> <!-- ← 커넥션 풀 -->
<property name="driverClassName" value="${driver}"/>
<property name="url" value="${url}"/> <!-- ← properties 값으로 채워짐 -->
<property name="username" value="${username}"/>
<property name="password" value="${password}"/>
<property name="defaultAutoCommit" value="false"/>
</bean>
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource"/> <!-- ← ref: 위의 빈 -->
<property name="configLocation" value="classpath:sqls/Configuration.xml"/>
</bean>
<!-- 쿼리를 실행할 객체 등록 -->
<bean id="sqlSessionTemplate" class="org.mybatis.spring.SqlSessionTemplate">
<constructor-arg index="0" ref="sqlSessionFactory" /> <!-- ← 생성자로 공장을 받음 -->
</bean>
JAVAHkDao.java — DAO가 받는 것은 창구 하나
@Autowired
private SqlSessionTemplate sqlSession; // ← root-context 의 sqlSessionTemplate 빈
@Override
public List<HkDto> getAllList() {
return sqlSession.selectList(namespace+"boardList"); // ← 연결 열기·닫기 코드가 없다
}
43번 정리 — 핵심 정리
연결 순서: DataSource → SqlSessionFactory → SqlSessionTemplate, DAO는 마지막 것만 주입받는다.
커넥션 풀 = 연결을 미리 만들어 두고 빌려주고 돌려받아 재사용.
value는 값, ref는 다른 빈의 id를 가리킨다.
${url} 같은 자리는 db.properties에서 채운다 — 실제 파일은 커밋하지 않고 .example만 둔다.
DAO에는 Connection·close 코드가 사라진다 — 템플릿이 대신한다.
defaultAutoCommit=false ≠ 트랜잭션. 트랜잭션 없이 부른 SQL은 호출마다 커밋된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
ref="dataSource"를 value="dataSource"로 잘못 쓰면 어떻게 되나?
value는 "dataSource"라는 글자 자체를 넘깁니다. dataSource 속성은 DataSource 객체를 기대하는데 문자열이 들어오니 타입을 바꿀 수 없다는 오류로 서버 시작이 실패합니다. ref여야 id가 dataSource인 빈 객체가 넘어갑니다.
autoCommit을 false로 했으니 글쓰기 두 번 중 두 번째가 실패하면 첫 번째도 취소될까?
아닙니다. 트랜잭션 매니저와 @Transactional이 없으면 SqlSessionTemplate은 호출 하나가 끝날 때마다 커밋합니다. 첫 번째 INSERT는 이미 저장된 상태라 그대로 남습니다. 둘을 묶으려면 트랜잭션 설정이 필요합니다(web-59).
💼 실무·코딩테스트에서는실무에서는 커넥션 풀 없이 운영하는 서비스는 거의 없습니다. 스프링 부트는 기본으로 HikariCP라는 풀을 쓰고, 최대 연결 수·대기 시간 같은 값을 튜닝합니다. "풀이 바닥나서 요청이 멈췄다"는 장애는 대개 연결을 반납하지 않는 코드나 너무 오래 붙잡는 트랜잭션이 원인이에요. 그리고 DB 비밀번호를 소스에 적어 커밋하는 실수는 실제 보안 사고로 이어지므로, 지금처럼 설정 파일을 분리하는 습관이 중요합니다.
44
MyBatis가 SQL을 찾는 이름 — namespace + id
MyBatisMapper XMLnamespace#{}
한 줄 요약DAO가 sqlSession.selectList("com.hk.board.dao." + "boardList")처럼 넘긴 문자열이 Mapper XML의 namespace + . + id와 대소문자까지 정확히 같아야 MyBatis가 그 SQL을 찾아 실행한다.
쉽게 말하면도서관에서 책을 찾을 때 "서가 번호(namespace) + 책 번호(id)"로 찾는 것과 같습니다. 파일(Mapper XML) 이름이 맞는 것으로는 부족하고, 쿼리마다 붙은 전체 주소가 맞아야 해요. 그리고 이 주소는 그냥 문자열이라, 한 글자 틀려도 컴파일러는 아무 말 없이 넘어갑니다.
① statement의 전체 이름 = namespace.id
BoardMapper.xml의 <mapper namespace="com.hk.board.dao"> 안에 <select id="boardList">가 있으면 그 SQL의 전체 이름은 com.hk.board.dao.boardList입니다. DAO는 namespace 필드에 끝의 점까지 포함한 "com.hk.board.dao."를 넣어 두고 id를 이어 붙입니다. selectList는 목록, selectOne은 한 객체를 돌려받습니다.
② 별칭과 Mapper 등록 — Configuration.xml
resultType="HkDto"의 HkDto는 Configuration.xml의 <typeAlias type="com.hk.board.dtos.HkDto" alias="HkDto"/>로 만든 짧은 별명입니다. Mapper XML도 같은 파일의 <mappers>에 등록되어야 MyBatis가 읽습니다. 조회 결과의 컬럼(SEQ, TITLE…)을 DTO의 같은 이름 필드로 옮기는 일도 MyBatis가 맡습니다.
③ Java 패키지는 daos, namespace는 dao — 괜찮은가?
실제 Java 패키지는 com.hk.board.daos이고 Mapper namespace는 com.hk.board.dao입니다. 이 수업은 인터페이스 Mapper 프록시가 아니라 문자열로 쿼리를 호출하므로, 두 이름을 기계적으로 같게 고칠 필요는 없습니다. DAO가 요청한 문자열과 XML의 이름이 맞는지가 핵심입니다.
JAVAHkDao.java — statement 이름을 조립해 호출
private String namespace="com.hk.board.dao."; // ← 끝의 점(.)까지 포함
@Override
public List<HkDto> getAllList() {
return sqlSession.selectList(namespace+"boardList"); // ← com.hk.board.dao.boardList
}
@Override
public HkDto getBoard(int seq) {
return sqlSession.selectOne(namespace+"getBoard",seq); // ← 두 번째 인자가 #{seq} 로
}
XMLBoardMapper.xml — namespace와 id
<!-- namespace속성: 반드시 정의하자 -->
<mapper namespace="com.hk.board.dao"> <!-- ← 서가 번호 -->
<select id="boardList" resultType="HkDto"> <!-- ← 책 번호 -->
SELECT SEQ, ID, TITLE, CONTENT, REGDATE
FROM HKBOARD ORDER BY REGDATE DESC
</select>
<select id ="getBoard" parameterType="Integer" resultType="HkDto">
SELECT SEQ, ID, TITLE, CONTENT, REGDATE
FROM HKBOARD WHERE SEQ = #{seq} <!-- ← ? 로 바뀌어 값이 바인딩됨 -->
</select>
</mapper>
44번 정리 — 핵심 정리
쿼리의 전체 이름 = namespace.id → com.hk.board.dao.boardList.
이름은 문자열이라 오타가 컴파일에서 잡히지 않고 실행할 때 드러난다. 대소문자도 구분한다.
selectList → List, selectOne → 객체 하나(없으면 null).
resultType="HkDto"는 Configuration.xml의 별칭이다.
문자열 호출 방식이면 Java 패키지(daos)와 namespace(dao)가 같을 필요는 없다.
#{}는 ? 바인딩이라 안전하고, ${}는 글자를 SQL에 그대로 붙여 SQL 인젝션 위험이 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
DAO에서 "boardList"를 "boardlist"로 적으면 컴파일에서 잡힐까?
잡히지 않습니다. 둘 다 그냥 문자열이라 컴파일은 통과합니다. 대신 목록 요청을 실제로 보낼 때 MyBatis가 com.hk.board.dao.boardlist라는 statement를 찾지 못해 예외가 나고 화면은 500이 됩니다. 오류 메시지의 "Mapped Statements collection does not contain value for ..." 뒤에 찍힌 이름을 XML과 한 글자씩 비교하면 됩니다.
namespace 필드에서 끝의 점을 빼고 "com.hk.board.dao"라고 쓰면?
이어 붙인 결과가 com.hk.board.daoboardList가 되어 역시 statement를 찾지 못합니다. 문자열을 조립하는 방식이라 이런 작은 실수가 생기기 쉽고, 그래서 실무에서는 아래처럼 인터페이스 Mapper를 많이 씁니다.
💼 실무·코딩테스트에서는실무 MyBatis에서는 문자열 대신 Mapper 인터페이스(@Mapper interface BoardMapper { List<HkDto> boardList(); })를 만들고, namespace를 그 인터페이스의 전체 이름과 똑같이 맞춥니다. 그러면 메서드 이름이 곧 id가 되어 오타를 IDE가 잡아 줍니다. 면접에서는 "#{}와 ${}의 차이"가 단골이에요 — #{}는 PreparedStatement의 ?, ${}는 문자열 치환이라 정렬 컬럼명처럼 꼭 필요한 곳에만, 허용 값 목록으로 검사한 뒤 씁니다.
45
여러 글 삭제와 foreach — 개수를 모르는 IN 절 만들기
동적 SQLforeachIN 절Map 파라미터
한 줄 요약체크한 글 번호 배열을 Map의 "seqs" 키에 담아 넘기면, Mapper의 <foreach>가 배열 길이만큼 IN (?, ?, …) 자리를 만들어 한 번의 DELETE로 여러 글을 지운다.
쉽게 말하면단체 주문서에 몇 명이 올지 모르는 상태로 칸을 미리 그려 둘 수는 없죠. foreach는 "온 사람 수만큼 칸을 그려 줘"라는 지시입니다. 2명이 오면 칸 2개, 5명이 오면 칸 5개가 그 자리에서 만들어집니다.
① 화면에서 DAO까지 — 배열 하나가 내려가는 길
목록의 체크박스는 모두 name="seq"라 seq=2&seq=5처럼 전송되고, Controller의 /mulDel.do가 @RequestParam("seq") String[]로 받아 Service → DAO → Mapper로 넘깁니다(받는 방법은 🖥️ 48). DAO는 그 배열을 Map에 "seqs"라는 이름으로 넣습니다.
② foreach의 다섯 속성
collection은 Map의 키(반복할 대상), item은 이번 반복 원소의 이름, open·close는 앞뒤에 붙일 괄호, separator는 사이에 넣을 쉼표입니다. 배열이 ["2", "5"]이면 SQL에는 IN (?, ?)가 만들어지고 값 2와 5가 바인딩됩니다. 안쪽이 #{seq}이므로 값을 SQL 글자로 붙이는 게 아니라 바인딩하는 것이라 안전합니다.
③ 수업 주석을 정확히 읽기
DAO 주석에는 "동적쿼리에 파라미터를 전달할 경우 Map에 담아서 전달해줘야 한다"고 되어 있지만, 동적 SQL이 언제나 Map을 요구하는 것은 아닙니다. 전달 형태에 따라 배열·목록·DTO도 사용할 수 있습니다. 또 현재 코드에서는 빈 배열을 DAO에 보내면 IN ()처럼 유효하지 않은 SQL이 되므로, 요청을 받을 때 서버에서도 선택값을 검사해야 합니다.
JAVAHkDao.java — 배열을 Map에 담아 넘기기
@Override
public boolean mulDel(String[] seqs) {
//동적쿼리에 파라미터를 전달할 경우
//Map에 담아서 전달해줘야 한다.
Map<String, String[]> map = new HashMap<>();
map.put("seqs", seqs); // ← 키 "seqs" = XML 의 collection
int count=sqlSession.delete(namespace+"mulDel",map);
return count>0; // ← 1건 이상 지워졌으면 true
}
XMLBoardMapper.xml — foreach로 IN 절 만들기
<delete id="mulDel" parameterType="Map">
DELETE FROM HKBOARD WHERE SEQ IN
<foreach collection="seqs" item="seq" open="(" close=")" separator=","> <!-- ← Map 의 키 -->
#{seq} <!-- ← 원소 하나 → ? 하나 -->
</foreach>
</delete>
<!-- seqs = ["2", "5"] 일 때 만들어지는 SQL
DELETE FROM HKBOARD WHERE SEQ IN ( ? , ? ) 값: 2, 5 -->
45번 정리 — 핵심 정리
개수를 모르는 값 목록은 <foreach>로 IN (?, ?, …)를 만든다.
collection = 반복할 대상의 이름(여기서는 Map 키 seqs), item = 원소 하나의 이름.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
count > 0이면 요청한 모든 글이 삭제됐다는 뜻인가?
아닙니다. 하나 이상의 행이 영향을 받았다는 뜻일 뿐입니다. 예를 들어 3개를 요청했는데 그중 하나를 다른 사람이 먼저 지웠다면 count는 2지만 결과는 true입니다. "전부 지워졌는가"를 알고 싶다면 count == seqs.length처럼 요청 건수와 비교해야 합니다.
XML에서 collection="seqs"를 collection="seq"로 바꾸면?
Map에는 "seqs" 키만 있으므로 MyBatis가 seq라는 반복 대상을 찾지 못해 실행할 때 예외가 납니다. collection은 DAO가 put한 키 이름과, #{…} 안의 이름은 item에 적은 이름과 맞아야 합니다.
💼 실무·코딩테스트에서는"선택 항목 일괄 삭제·일괄 상태 변경"은 관리자 화면마다 나오는 기능이고, <foreach>로 만든 IN 절이 그 표준 답입니다. 실무에서는 빈 목록 검사를 꼭 넣고, 목록이 너무 길면(수천 개) 나눠서 실행합니다. 또 지금 화면은 누가 썼든 번호만 맞으면 지워지는 구조라, 실제 서비스에서는 "로그인한 사람의 글인지"를 WHERE 조건에 함께 넣어 권한을 확인합니다.
46
오류를 단계별로 좁히기 — 빌드 오류 · 404 · 500
디버깅404500Caused by배포
한 줄 요약웹 프로젝트의 오류는 빌드 → 요청 전달(URL 매핑) → 실행(DB 등) → 화면 중 어느 단계에서 났는지부터 가르면 확인할 곳이 확 줄어든다. 404는 "주소와 매핑·파일이 어긋남", 500은 "도착은 했는데 실행 중 예외"다.
쉽게 말하면택배가 안 왔을 때 "포장(빌드)은 됐나 → 주소(URL)는 맞나 → 배송 중(실행)에 사고가 났나 → 문 앞(화면)까지 왔나"를 순서대로 묻는 것과 같습니다. 어디까지 성공했는지가 다음 확인 지점을 알려 줍니다. 무작정 여러 곳을 동시에 고치면 무엇이 진짜 원인이었는지 모른 채 넘어가게 돼요.
스프링 프로젝트는 설정 파일이 여러 개라, 같은 "안 열린다"도 원인이 전혀 다른 곳에 있을 수 있습니다. 아래 표는 07 프로젝트를 처음 띄우면서 실제로 겪은 증상을 단계별로 정리한 것입니다.
<!-- web.xml : DispatcherServlet 은 *.do 로 끝나는 요청만 받는다 -->
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>*.do</url-pattern> <!-- ← .board 는 여기에 안 걸린다 -->
</servlet-mapping>
<!-- index.jsp : 처음엔 boardlist.board 였다 → 404 -->
<a href="boardlist.do">게시판목록</a> <!-- ← Controller 의 /boardlist.do 와 일치시킴 -->
코드가 컴파일된다고 모든 URL이 존재하는 것은 아니다 — 404는 "주소와 매핑·파일이 어긋남", 500은 "도착은 했는데 실행 중 예외"다.
404면 링크 주소 · url-pattern · @RequestMapping · JSP 파일 위치 네 가지를 나란히 놓고 비교한다.
500이면 톰캣 콘솔 로그의 마지막 Caused by부터 읽는다.
고쳤는데 그대로면 배포본이 옛것인지 의심한다 — 재시작·Clean.
GitHub Pages는 이 학습 노트를 보여 주는 정적 사이트이며 JSP·Spring 서버를 실행하지 않는다. 공개 소스를 실행하려면 예제 DB 설정을 본인 환경에 맞춰 채워야 한다 — 07 프로젝트의 실행 안내.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
boardDetail.jsp 파일이 있는데 주소창으로 WEB-INF/views/boardDetail.jsp를 열면 되는가?
안 됩니다. WEB-INF 아래의 자원은 브라우저가 직접 요청할 수 없습니다(404). boardDetail.do처럼 Controller를 거쳐, Controller가 뷰 이름을 반환해 서버 안에서 forward해야 열립니다.
500 화면의 스택 트레이스가 수십 줄이다. 어디부터 읽어야 하나?
맨 아래쪽의 마지막 Caused by부터 읽습니다. 위쪽 예외들은 대개 아래 예외를 감싸서 다시 던진 것이라, 진짜 원인은 가장 안쪽(마지막 Caused by)에 있습니다. 예를 들어 DB 인증 실패라면 그 줄에 SQLException과 접근 거부 메시지가 보입니다.
💼 실무·코딩테스트에서는실무에서 장애 대응의 첫 질문은 "어디까지는 정상이었나"입니다. 브라우저 개발자 도구의 Network 탭에서 상태 코드(404/405/500)를 먼저 보고, 500이면 서버 로그로, 404면 주소·매핑으로 갑니다. 면접에서 "HTTP 상태 코드 4xx와 5xx의 차이"를 물으면 "4xx는 요청 쪽 문제(없는 주소, 잘못된 방식), 5xx는 서버가 처리하다 실패"라고 답하면 됩니다.
정리 작업 메모
2026-09-23에 07에서 겪은 순서: ① Eclipse의 Maven 클래스패스 연결 누락으로 Spring·MyBatis import와 JSTL 인식 오류 → Maven Dependencies·resources 경로 복구 ② index.jsp 링크는 boardlist.board, Controller는 boardlist.do라 404 → 링크 수정 ③ 로컬 DB의 root 인증 실패로 500 → 접속을 확인한 수업용 계정 설정 사용 ④ 설정을 고친 뒤에도 배포본에 예전 DB 설정이 남아 같은 오류 → 리소스 다시 반영.
확인함 — 로컬 Tomcat 10.1에서 GET /07_hkboard_springMVC/ → 200, GET /07_hkboard_springMVC/boardlist.do → 200, 목록 JSP와 실제 DB 조회까지 확인했다.
47
요청 하나에 메서드 하나 — 게시판 CRUD 매핑표
@RequestMappingCRUDURL 설계GET/POST
한 줄 요약MVC2의 Controller 서블릿이 URL을 보고 if문으로 갈라내던 일을, 스프링에서는 메서드마다 붙인 @RequestMapping이 대신한다. 게시판의 목록·글쓰기·상세·수정·삭제가 URL 하나에 메서드 하나로 정리된다.
쉽게 말하면예전엔 안내 데스크 한 명이 모든 손님의 용건을 듣고 부서를 정해 줬다면, 이제는 문마다 부서 이름표가 붙어 있어 손님이 바로 찾아갑니다. 새 기능이 생기면 데스크 직원에게 규칙을 하나 더 외우게 하는 대신 문을 하나 더 달면 됩니다.
TEXTBoardController 매핑표 — 수업 코드 기준
URL 방식 받는 값 하는 일 돌려주는 것
─────────────────── ──────── ─────────────────────────────── ────────────────── ─────────────────────────────
/boardlist.do GET — 목록 조회 → Model "boardlist" (forward)
/boardInsertForm.do GET — 빈 폼 보여 주기 "boardInsertForm"
/boardInsert.do POST id·title·content → HkDto 글 저장 redirect:boardlist.do
/boardDetail.do GET seq → HkDto pdto 한 건 조회 → Model "boardDetail"
/boardUpdate.do POST seq·title·content → HkDto 글 수정 redirect:boardDetail.do?seq=…
/mulDel.do GET·POST @RequestParam("seq") String[] 한 건/여러 건 삭제 redirect:boardlist.do
실패하면 저장·수정·삭제 모두 redirect:error.jsp ← 함정 (→ web-50)
표를 세로로 읽으면 규칙이 보입니다. 화면을 보여 주는 요청은 GET이고 뷰 이름을 돌려주며(forward), 데이터를 바꾸는 요청은 POST이고 redirect:로 다른 주소를 다시 열게 합니다(이유는 🖥️ 50). 받는 값은 대부분 HkDto 하나로 받고, 여러 개가 같은 이름으로 오는 체크박스만 배열로 받습니다(🖥️ 48).
글쓰기는 폼을 보여 주는 GET(boardInsertForm.do)과 저장하는 POST(boardInsert.do) 두 요청으로 나뉩니다. 둘 다 "글쓰기"지만 하는 일이 다르므로 메서드도 다릅니다.
JAVABoardController.java — 글쓰기는 두 요청
//boardlist.jsp와 BoardController.java
@RequestMapping(value = "boardInsertForm.do", method = RequestMethod.GET)
public String boardInsertForm() {
return "boardInsertForm"; // ← 폼 화면만 보여 준다 (forward)
}
@RequestMapping(value = "/boardInsert.do", method = RequestMethod.POST)
public String boardInsert(HkDto dto) { // ← id, title, content 가 dto 로 들어온다
//파라미터: HkDto가 받아준다(id, title, content)
boolean isS=hkService.insertBoard(dto);
if(isS) {
return "redirect:boardlist.do"; // ← 저장 후 목록을 새로 요청
}
else {
return "redirect:error.jsp"; // ← ※ 함정: error.jsp 는 WEB-INF/views 안 (→ web-50)
}
}
※ 실패 분기의 redirect:error.jsp는 수업 원본 그대로입니다. redirect는 브라우저가 주소를 새로 여는 것이라 /WEB-INF/views 안의 JSP에 닿을 수 없습니다 — 이유와 고치는 법은 web-50.
JSPboardInsertForm.jsp · boardDetail.jsp — 화면 쪽 주소도 .do로
<form action="boardInsert.do" method="post"> <%-- ← 예전 *.board 에서 *.do 로 --%>
<!-- <input type="hidden" name="command" value="boardInsert"/> --> <%-- ← MVC2 의 분기용 값, 이제 필요 없음 --%>
<form action="boardUpdate.do" method="post">
<input type="hidden" name="seq" value="${requestScope.dto.seq}" /> <%-- ← 수정할 글 번호도 함께 보냄 --%>
화면 쪽도 함께 바꿨습니다. 폼의 action과 링크를 *.board에서 *.do로 옮겨야 요청이 web.xml의 *.do 매핑을 거쳐 DispatcherServlet에 닿습니다. 37일차에 목록 링크 하나를 고쳤던 것을 나머지 화면에 모두 적용한 것입니다. 주석으로 남은 command hidden 값은 MVC2에서 if문 분기에 쓰던 흔적으로, URL이 곧 기능이 되면서 필요 없어졌습니다.
47번 정리 — 핵심 정리
URL 하나당 메서드 하나. 분기 코드는 사라지고 메서드 목록이 곧 기능 목차가 된다.
화면을 보여 주는 요청은 GET + 뷰 이름, 데이터를 바꾸는 요청은 POST + redirect:.
글쓰기·수정처럼 "폼 보여 주기"와 "처리하기"는 다른 요청이다.
JSP의 주소와 @RequestMapping 값이 어긋나면 404다 — 🖥️ 46. 오류 좁히기와 같은 원리.
value = "boardInsertForm.do"처럼 앞의 /가 빠져도 스프링이 붙여서 처리하지만, 한 가지 모양으로 통일하는 편이 읽기 좋다.
실패 시 redirect:error.jsp는 WEB-INF 안이라 열리지 않는다(→ web-50).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
주소창에 boardInsert.do를 직접 쳐서 열면 어떻게 되나?
주소창 입력은 GET 요청인데 boardInsert.do는 method = RequestMethod.POST만 받도록 매핑되어 있습니다. 주소는 맞지만 방식이 틀렸으므로 스프링이 405 Method Not Allowed로 거절합니다. 404(주소 없음)와 구분해 두세요.
MVC2에서 hidden으로 보내던 command 값이 왜 필요 없어졌나?
MVC2에서는 한 서블릿이 모든 요청을 받아command 값으로 if문 분기를 했습니다. 이제는 URL마다 다른 메서드가 연결되어 있으니, 주소 자체가 "무엇을 할지"를 말해 줍니다. 그래서 별도의 분기용 값이 필요 없습니다.
💼 실무·코딩테스트에서는실무에서는 이 매핑표가 곧 API 명세서입니다. 새 기능을 맡으면 먼저 "어떤 URL에 어떤 방식으로, 무엇을 받아, 무엇을 돌려주는가"를 표로 정리하고 시작해요. 요즘 REST 방식에서는 GET /boards, POST /boards, GET /boards/10처럼 동사를 URL에서 빼고 HTTP 방식으로 구분하는데, 지금 배우는 "방식에 따라 의미가 다르다"는 감각이 그 출발점입니다.
48
폼 값이 자바 객체로 들어오는 방법 — 커맨드 객체와 @RequestParam
데이터 바인딩커맨드 객체@RequestParam체크박스 배열
한 줄 요약매개변수를 DTO로 선언하면 스프링이 요청 파라미터 이름과 같은 필드를 setter로 채워 준다(커맨드 객체). 값 하나나 같은 이름의 여러 값(배열)은 @RequestParam("이름")으로 이름을 적어 받는다.
쉽게 말하면택배 상자(HkDto)에 칸 이름(id·title·content)이 적혀 있어서, 같은 이름표가 붙은 물건(폼 값)이 알아서 제 칸에 들어갑니다. 예전처럼 request.getParameter("title")로 하나씩 꺼내 setTitle에 옮겨 담던 일을 스프링이 대신해 주는 거예요.
① 커맨드 객체 — HkDto dto
폼의 <input name="title">은 title=…로 전송됩니다. 매개변수가 HkDto dto면 스프링이 HkDto를 기본 생성자로 만들고 setTitle(…)을 부릅니다. 이름이 필드와 같아야 하고 setter가 있어야 합니다. 숫자 필드도 자동으로 변환됩니다 — seq=10이 int seq로 들어가죠. getParameter는 전부 String이라 Integer.parseInt를 직접 하던 때와 비교해 보세요.
② 값 하나 — 상세보기의 seq
상세보기는 ?seq=10 하나만 받습니다. 수업 코드는 HkDto pdto로 받아 pdto.getSeq()를 썼고, 주석으로 @RequestParam("seq") int seq도 남겨 두었습니다. 둘 다 동작합니다. DTO는 받는 값이 늘어나도 매개변수가 그대로라는 장점이, @RequestParam은 무엇을 받는지 한눈에 보이고 값이 없으면 바로 거절한다는 장점이 있습니다.
③ 같은 이름이 여러 개 — 체크박스
목록의 체크박스는 모두 name="seq"라서 seq=3&seq=5&seq=9처럼 전송됩니다. @RequestParam("seq") String[] seq로 받으면 배열이 됩니다. 이 배열이 Service → DAO를 거쳐 Mapper의 <foreach>로 넘어가 DELETE FROM HKBOARD WHERE SEQ IN (3, 5, 9)가 됩니다(→ 🖥️ 45).
@Override
public boolean insertBoard(HkDto dto) {
//파라미터가 4개라면? 4개를 전달하고 싶다
// -> dto에 담아서 전달
// -> dto에 없는 이름일 경우: num1, num2, num3
// Map을 활용 map.put("num1",5) .put("num2",10)...
//myBatis는 원래 Map을 통해 파라미터를 전달함
int count =sqlSession.insert(namespace+"insertBoard",dto); // ← 파라미터 자리는 하나뿐
return count>0;
}
sqlSession.insert(id, 파라미터)는 파라미터를 하나만 받습니다. 그래서 여러 값은 DTO에 담고, DTO에 없는 이름이면 Map에 put("num1", 5)처럼 담아 넘깁니다. Mapper의 #{num1}이 그 키를 읽습니다. 다중 삭제도 배열을 Map의 seqs 키에 담아 넘깁니다. 정리하면 Controller 쪽은 스프링이, DAO 쪽은 MyBatis가 "이름으로 값을 맞춰 넣는" 일을 해 줍니다.
48번 정리 — 핵심 정리
DTO 매개변수(커맨드 객체) = 이름이 같은 필드를 setter로 채움. 숫자 변환도 스프링이 한다.
폼의 name ↔ DTO 필드 이름 ↔ setter 이름이 모두 맞아야 값이 들어간다.
값 하나는 @RequestParam("이름"), 같은 name이 여러 개면 배열로 받는다.
@RequestParam은 기본이 필수(required=true) — 값이 없으면 400으로 거절한다.
MyBatis에 여러 값을 넘길 때는 DTO 또는 Map 하나로 묶는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
글쓰기 폼의 <input name="title">을 name="subject"로 바꾸면 어떻게 되나?
바인딩 단계에서는 아무 오류도 나지 않습니다.HkDto에는 setSubject가 없으니 스프링은 그 값을 넣을 곳이 없어 조용히 무시하고, title은 null로 남습니다. 문제는 한참 뒤에 드러나요 — 실습 테이블은 title이 NOT NULL이라 INSERT 단계에서 DB가 거절하고, 그 예외가 Controller까지 올라와 500이 됩니다. 원인(폼 name)과 증상(DB 오류)이 멀리 떨어진 이런 실수가 커맨드 객체의 함정이라, 폼 name과 필드 이름을 함께 확인하는 습관이 필요합니다.
boardDetail.do를 ?seq 없이 열면 수업 코드(HkDto pdto)와 @RequestParam("seq") int seq는 어떻게 다르게 동작하나?
HkDto pdto는 그냥 통과합니다. 채울 값이 없으니 seq는 int 기본값 0으로 남고, getBoard(0)은 해당 글이 없어 null을 돌려받아 화면 칸이 비어 나옵니다. 반면 @RequestParam("seq") int seq는 기본이 필수라, 값이 없으면 메서드에 들어가기도 전에 400 Bad Request로 거절합니다. 잘못된 요청을 일찍, 분명하게 막는 쪽은 후자입니다.
💼 실무·코딩테스트에서는실무에서는 커맨드 객체에 @Valid와 검증 애너테이션(@NotBlank, @Size 등)을 붙여 "제목은 비면 안 된다" 같은 규칙을 서버에서 검사합니다. 브라우저의 required만으로는 부족하기 때문이에요. 또 DTO를 그대로 받으면 폼에 없던 필드까지 요청으로 채워질 수 있다는 점(예: 누군가 id=admin을 끼워 보냄)을 알고, 받을 값만 가진 요청 전용 DTO를 따로 두는 경우가 많습니다.
49
이름을 안 적으면 왜 터지나 — Spring 6.1과 -parameters
@RequestParam-parametersSpring 6.1실제 재현
한 줄 요약@RequestParam 없이 String param처럼 받으면 스프링은 매개변수 이름으로 요청 값을 찾는다. 그 이름은 -parameters 옵션으로 컴파일해야 class 파일에 남으므로, -parameters 없이 컴파일하면 Spring 6.1은 이름을 읽지 못해 예외를 낸다. 강사님 주석 "Spring6 에서는 파라미터 받을때 @RequestParam 명시하기"가 바로 이 이야기다.
쉽게 말하면"param이라고 적힌 상자를 줘"라고 해야 하는데, 컴파일하면서 이름표가 떨어져 나가 스프링이 무엇을 찾아야 할지 모르는 상태입니다. 해결책은 둘 — 이름표를 직접 크게 써 붙이거나(@RequestParam("param")), 이름표가 안 떨어지게 포장하거나(-parameters).
① 매개변수 이름은 원래 사라진다
자바 컴파일러는 기본적으로 메서드 매개변수의 이름을 class 파일에 남기지 않습니다. 실행에는 순서와 타입만 있으면 되니까요. javac -parameters로 컴파일해야 MethodParameters라는 정보로 이름이 남고, 스프링이 리플렉션으로 그 이름을 읽을 수 있습니다. 수업 프로젝트의 pom.xml은 maven-compiler-plugin에 <release>만 있고 이 옵션은 없습니다.
② Spring 5와 6.1의 차이
Spring 5까지는 디버그 정보에서 이름을 읽어 오는 방법도 함께 썼기 때문에 옵션 없이도 대부분 동작했습니다. Spring 6.1에서 그 방법이 빠졌습니다. 그래서 예전 자료를 따라 하면 되던 코드가 지금은 터지는 일이 생깁니다.
③ 어떤 매개변수가 영향을 받나
String param(애너테이션 없음) — 이름으로 요청 값을 찾으므로 -parameters 없으면 예외. @RequestParam("seq") String[] seq — 괄호 안에 이름을 적었으니 ✅. HkDto dto(커맨드 객체) — 필드 이름으로 채우므로 매개변수 이름이 필요 없어 ✅. Model·HttpServletRequest — 타입만 보고 넣어 주므로 ✅.
JAVABoardController.home — 수업 코드 그대로
@RequestMapping(value = "/home.do", method = RequestMethod.GET )
public String home(Model model,
String param, //"param"이라는 이름의 값이 넘어오면 // ← 이름이 필요한데 적지 않음
HkDto dto, // dto에 맴버필드명과 일치하는 값이 넘어오면
HttpServletRequest request) {
// request.setAttribute("param", "파람");
model.addAttribute("param", "파람");
// String param=request.getParameter("param");
return "home";//forward
}
//Spring6 에서는 파라미터 받을때 @RequestParam 명시하기
@RequestMapping(value = "/mulDel.do", method = {RequestMethod.GET,RequestMethod.POST})
public String mulDel(@RequestParam("seq")String[] seq) { // ← 이름을 직접 적어 안전
실행 결과스프링의 매개변수 해석기에 수업 코드를 직접 넣어 봤다 먼저 예측 → 펼쳐서 확인
── -parameters 없이 컴파일 (수업 프로젝트의 Eclipse·pom 기본 설정과 같음) ──
스프링이 읽은 매개변수 이름: null (이름 정보 없음)
String param 해석 결과: IllegalArgumentException
메시지: Name for argument of type [java.lang.String] not specified,
and parameter name information not available via reflection.
Ensure that the compiler uses the '-parameters' flag.
── -parameters 로 컴파일 ──
스프링이 읽은 매개변수 이름: model, param, dto, request
String param 해석 결과: null → 정상 (값이 없으니 null)
Eclipse가 실제로 배포한 BoardController.class에도 매개변수 이름 정보(MethodParameters)가 0건이었다. 즉 -parameters 없이 컴파일하면 /home.do를 열 때 이 예외로 요청이 실패한다. 강사님이 주석으로 남긴 "Spring6 에서는 파라미터 받을때 @RequestParam 명시하기"가 바로 이 상황이다.
Spring 5까지는 디버그 정보에서 이름을 읽어 오는 방법도 함께 썼기 때문에 옵션 없이도 대부분 동작했다. Spring 6.1에서 그 방법이 빠졌다.
고치는 방법 두 가지. ① 이름을 직접 적는다 — @RequestParam(value = "param", required = false) String param. 값이 없어도 되면 required = false를 함께 씁니다(기본값 true라 없으면 400). ② 컴파일 옵션을 켠다 — Maven은 maven-compiler-plugin에 <parameters>true</parameters>, Eclipse는 Java Compiler 설정의 "Store information about method parameters". 수업 주석처럼 ①로 명시하는 습관이 어느 빌드에서나 안전합니다.
하나 더 — model.addAttribute("param", …)의 이름 param은 JSP EL의 내장 객체 이름과 같습니다. JSP에서 ${param}이라고 쓰면 모델 값이 아니라 요청 파라미터 묶음이 나옵니다. 수업의 home.jsp는 이 값을 출력하지 않아 드러나지 않았지만, 모델 이름으로 param·header·cookie 같은 EL 내장 이름은 피하는 편이 좋습니다.
49번 정리 — 핵심 정리
매개변수 이름은 -parameters로 컴파일해야 class 파일에 남는다.
Spring 6.1 + -parameters 없음 → 애너테이션 없는 단순 타입 매개변수는 예외.
@RequestParam("이름")으로 적으면 빌드 설정과 무관하게 동작한다.
DTO·Model·request는 매개변수 이름이 필요 없어 영향이 없다.
@RequestParam은 기본 필수 — 없어도 되면 required = false.
모델 이름으로 param·header·cookie 같은 EL 내장 객체 이름은 피한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
같은 컴퓨터에서 boardInsert(HkDto dto)는 잘 되는데 home(…, String param, …)만 터지는 이유는?
이름이 필요한 매개변수인가의 차이입니다. HkDto는 DTO의 필드 이름(setter)으로 값을 채우니 매개변수 이름을 몰라도 됩니다. 반면 String param은 "요청 파라미터 중 매개변수 이름과 같은 것"을 찾아야 하는데, 그 이름이 class 파일에 없으니 찾을 수가 없습니다.
JSP에서 ${param}을 찍으면 Controller가 넣은 "파람"이 나올까?
나오지 않습니다. EL은 param이라는 이름을 내장 객체(요청 파라미터 묶음)로 먼저 해석하므로, 모델에 같은 이름으로 넣은 값은 가려집니다. 모델 이름을 msg처럼 겹치지 않게 바꾸면 해결됩니다.
💼 실무·코딩테스트에서는스프링 부트의 기본 빌드 설정(부모 pom·Gradle 플러그인)을 쓰는 프로젝트는 -parameters가 기본으로 켜져 있어서 이 문제를 잘 못 만납니다. 그래서 오히려 부트를 쓰지 않는 레거시 프로젝트를 Spring 6로 올릴 때 갑자기 터지는 대표적인 마이그레이션 함정이에요. 원인을 알면 오류 메시지의 "Ensure that the compiler uses the '-parameters' flag" 한 줄만 보고 바로 고칠 수 있습니다. 팀 코드에서는 이름을 명시하는 쪽이 빌드 환경에 기대지 않아 더 안전합니다.
50
저장한 뒤엔 왜 redirect인가 — forward와 redirect
forwardredirect:PRGWEB-INF
한 줄 요약Controller가 뷰 이름만 돌려주면 forward(서버 안에서 JSP로 넘김, request 유지), redirect:를 붙이면 브라우저에게 다른 주소로 새로 요청하라고 알린다. 데이터를 바꾼 뒤에는 redirect를 써서 새로고침 중복 저장을 막는다(PRG).
쉽게 말하면forward는 창구 직원이 안쪽 서류를 대신 가져다주는 것이고, redirect는 "저쪽 창구로 가세요"라며 번호표를 새로 뽑게 하는 것입니다. 앞의 경우 손님은 자리를 옮기지 않았으니 처음 들고 온 서류(request)가 그대로 있고, 뒤의 경우 손님이 새로 줄을 서니 앞 창구에서 받은 메모(Model)는 남지 않습니다.
TEXTforward와 redirect 비교
forward — return "boardlist" redirect — return "redirect:boardlist.do"
────────────── ────────────────────────────────── ──────────────────────────────────────────
요청 수 1번 (서버 안에서 JSP 로 넘김) 2번 (브라우저가 새 주소로 다시 요청)
주소창 처음 주소 그대로 새 주소로 바뀜
Model 값 JSP 에서 그대로 사용 사라짐 — 필요한 값은 주소에 실어 보냄
새로고침하면 처음 요청을 다시 보냄 마지막 GET 만 다시 보냄
WEB-INF 안 JSP 열 수 있음 (서버 내부 이동) 열 수 없음 (브라우저의 직접 요청)
① PRG — Post → Redirect → Get
글을 저장한 뒤 forward로 목록을 보여 주면 주소창은 여전히 boardInsert.do(POST)입니다. 새로고침하면 같은 글이 한 번 더 저장되죠. 그래서 저장·수정·삭제 뒤에는 redirect로 GET 주소를 새로 열게 합니다. 이 순서를 PRG(Post → Redirect → Get)라고 부릅니다. sendRedirect를 직접 부르던 것을 이제 문자열 한 줄로 씁니다.
② redirect할 때 값 넘기기 — 주소에 싣는다
수정 뒤에는 "redirect:boardDetail.do?seq=" + dto.getSeq()로 보냅니다. redirect는 Model이 사라지므로 어느 글을 보여 줄지 seq를 주소에 실어 다음 요청이 다시 조회하게 합니다.
③ 수업 코드의 함정 — redirect:error.jsp
error.jsp는 /WEB-INF/views/error.jsp에 있습니다. redirect는 브라우저가 그 주소를 직접 여는 것이라 WEB-INF 안으로 들어갈 수 없고, 웹앱 바로 아래에는 error.jsp가 없습니다. 이 파일 배치 그대로라면 실패 시 화면을 찾지 못합니다. 실행해서 확인한 것은 아니고 파일 위치로 따진 결과입니다. 뷰 폴더의 화면을 보여 주려면 return "error";처럼 뷰 이름(forward)을 돌려주면 됩니다.
JAVABoardController.java — forward · redirect · 함정이 한 클래스에
public String boardList(Model model) {
...
return "boardlist"; //forward 방식 // ← 조회 결과를 보여 줌
//return "redirect:boardlist.do"; //리다이렉트 방식
}
public String boardUpdate(HkDto dto) {
// 파라미터 seq, title, content -> HkDto가 받음
boolean isS=hkService.updateBoard(dto);
if(isS) {
return "redirect:boardDetail.do?seq="+dto.getSeq(); // ← 어느 글인지 주소에 실음
}else {
return "redirect:error.jsp"; // ← 브라우저가 /07_hkboard_springMVC/error.jsp 를 새로 연다
}
}
50번 정리 — 핵심 정리
조회 결과를 보여 줄 때 → forward(뷰 이름). 요청 1번, 주소창 그대로, Model 사용 가능.
데이터를 바꾼 뒤 → redirect(새 GET 요청). 새로고침 중복 저장 방지 = PRG.
redirect는 Model이 사라진다. 필요한 값은 주소(?seq=)에 싣는다.
WEB-INF 안의 JSP는 redirect로 열 수 없다 — forward(뷰 이름)로만 열린다.
redirect:boardlist.do의 대상은 파일이 아니라 Controller의 URL이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
boardInsert가 성공 후 return "boardlist"(forward)를 하면 무엇이 문제인가? 두 가지를 말해 보자.
첫째, 목록이 비어 나옵니다.boardInsert는 Model에 list를 담지 않았으니 boardlist.jsp의 ${list}가 비어 있죠. 둘째, 주소창이 boardInsert.do로 남아 새로고침하면 같은 글이 또 저장됩니다. redirect:boardlist.do는 목록 조회를 Controller에게 다시 맡기면서 두 문제를 한 번에 풉니다.
수정 후 redirect:boardDetail.do로만 보내고 ?seq=를 빼면?
새 요청에는 seq가 없으니 HkDto pdto의 seq가 기본값 0이 되고, getBoard(0)은 글을 찾지 못해 빈 상세 화면이 나옵니다. redirect는 완전히 새로운 요청이라 앞 요청의 값이 저절로 따라가지 않는다는 것을 보여 주는 예입니다.
💼 실무·코딩테스트에서는"forward와 redirect의 차이"는 웹 백엔드 면접의 단골 질문입니다. "forward는 서버 내부 이동이라 요청 1번·주소 유지·request 공유, redirect는 브라우저 재요청이라 요청 2번·주소 변경·request 새로"에 PRG로 중복 제출을 막는다까지 말하면 충분합니다. 실무에서 redirect 뒤에 "저장되었습니다" 같은 일회성 메시지를 보여 주고 싶을 때는 스프링의 RedirectAttributes.addFlashAttribute()를 씁니다 — 다음 요청 한 번까지만 값을 살려 주는 장치예요.
51
같은 주소를 GET·POST로 나누기 — 그리고 삭제는 왜 POST인가
@GetMapping@PostMappingHTTP 메서드fmt:formatDate
한 줄 요약@GetMapping·@PostMapping은 @RequestMapping(method=…)의 줄임말이고, 주소가 같아도 방식이 다르면 다른 메서드로 보낼 수 있다. 그리고 데이터를 바꾸는 요청(삭제 포함)은 POST로 받는 것이 원칙이다. 덤으로 목록의 작성일을 fmt:formatDate로 다듬었다.
쉽게 말하면같은 문이라도 "구경하러 온 손님(GET)"과 "물건을 맡기러 온 손님(POST)"을 다른 직원이 맞습니다. 그리고 "구경"은 몇 번을 해도 가게에 변화가 없어야 해요. 그런데 문 앞을 지나가기만 해도(링크를 열기만 해도) 물건이 버려진다면 위험하겠죠 — 삭제를 GET으로 받으면 바로 그런 상태가 됩니다.
① 줄임 애너테이션과 연습용 mulDel2.do
@GetMapping("/x")는 @RequestMapping(value="/x", method=RequestMethod.GET)와 같습니다. 수업에서는 같은 /mulDel2.do에 GET용·POST용 메서드를 하나씩 만들어 "URL은 같고 로직은 다르게"를 연습했습니다. 두 메서드는 모양만 잡아 둔 연습용 자리로, 둘 다 빈 문자열을 돌려주고 연결된 화면이 없습니다.
② 실제로 쓰는 mulDel.do는 GET·POST를 모두 받는다
목록에서는 체크박스 폼을 POST로 보내고, 상세보기의 삭제 버튼은 location.href='mulDel.do?seq='+seq로 GET을 보내기 때문입니다. 지금은 두 화면을 모두 살리려고 둘 다 열어 둔 상태입니다. 그런데 주석에는 "삭제는 POST 권장"이라고 적혀 있습니다. 이유는 둘입니다. GET은 링크만 열어도 실행됩니다 — 주소를 공유하거나, 방문 기록에서 다시 열거나, 브라우저·메신저가 미리보기로 주소를 먼저 불러와도 삭제가 일어날 수 있어요. GET 주소는 기록에 남습니다 — 브라우저 방문 기록과 서버 로그에 seq=10이 그대로 남죠. 그래서 데이터를 바꾸는 요청은 POST로 받고, 상세보기의 삭제 버튼도 폼을 POST로 제출하는 방식이 권장됩니다.
③ 작성일 형식 바꾸기 — fmt:formatDate
fmt:formatDate는 java.util.Date를 원하는 글자 모양으로 바꿉니다. HkDto의 regDate가 Date 타입이라 바로 쓸 수 있습니다. 문자열 필드였다면 이 태그로는 바꿀 수 없습니다. 패턴의 MM은 월, mm은 분입니다.
JAVABoardController.java — 방식별 매핑 연습과 실제 삭제
//삭제기능: 글상세보기에서는 get요청
// 글목록에서는 post요청
//url패턴은 같으면서 로직을 다르게 처리할 경우
@GetMapping("/mulDel2.do") // ← = @RequestMapping(method = GET)
public String mulDelGet(){
return "";
}
@PostMapping("/mulDel2.do") // ← 같은 URL, 다른 방식 → 다른 메서드
public String mulDelPost() {
return "";
}
//삭제하는 기능은 post방식을 권장함
// mulDel.do?seq=10 주소창에 노출되면 위험하다.
@RequestMapping(value = "/mulDel.do", method = {RequestMethod.GET,RequestMethod.POST}) // ← 둘 다 받음
public String mulDel(@RequestParam("seq")String[] seq) { ... }
JSPboardDetail.jsp · boardlist.jsp — GET 삭제 버튼과 날짜 형식
<%-- boardDetail.jsp : 삭제 버튼이 GET 으로 주소를 연다 --%>
function boardDelete(seq){
if(confirm("정말 삭제하겠습니까?")){
location.href='mulDel.do?seq='+seq; // ← GET — 주소에 seq 가 드러난다
}
}
<%-- boardlist.jsp : 작성일 형식 --%>
<%@taglib uri="jakarta.tags.fmt" prefix="fmt" %>
<td><fmt:formatDate value="${dto.regDate}"
pattern="yyyy년 MM월 dd일"/> </td> <%-- ← MM = 월, mm = 분 --%>
51번 정리 — 핵심 정리
@GetMapping("/x") = @RequestMapping(value="/x", method=GET). @PostMapping도 같은 방식.
같은 URL도 방식이 다르면 다른 메서드가 처리한다.
매핑에 없는 방식으로 요청하면 405 Method Not Allowed.
데이터를 바꾸는 요청(삭제 포함)은 POST로 받는 것이 원칙 — GET은 링크만 열어도 실행되고 기록에 남는다.
수업의 mulDel.do는 화면 두 곳을 살리려고 GET·POST를 모두 열어 둔 상태다.
fmt:formatDate는 Date 타입에만. MM(월)과 mm(분)을 헷갈리지 않는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
상세보기의 삭제 버튼입니다. 그 버튼은 location.href로 주소를 여는 GET 요청이라 405로 거절됩니다. 목록의 체크박스 삭제는 <form method="post">라 그대로 동작하죠. POST만 받게 하려면 상세보기 쪽도 숨은 폼(seq hidden)을 만들어 POST로 제출하도록 함께 바꿔야 합니다.
pattern="yyyy년 mm월 dd일"로 잘못 쓰면 화면에 무엇이 나오나?
월 자리에 "분"이 찍힙니다.mm은 분(minute)이라, 시간 정보가 0시 0분이면 "2026년 00월 28일"처럼 이상한 값이 나옵니다. 오류 없이 엉뚱한 값이 나오는 실수라 대문자 MM = 월을 기억해 두세요.
💼 실무·코딩테스트에서는HTTP 방식에는 "안전(safe)"과 "멱등(idempotent)"이라는 약속이 있습니다. GET은 서버 상태를 바꾸지 않아야 하는 방식이라 브라우저·검색엔진·캐시가 마음 놓고 미리 불러오거나 다시 보냅니다. 그래서 상태를 바꾸는 일을 GET에 맡기면 사고가 나요. 실무 REST API는 아예 DELETE /boards/10처럼 DELETE 방식을 쓰고, 폼 기반 화면에서는 POST에 CSRF 토큰을 함께 보내 다른 사이트가 몰래 요청을 만들어 보내는 공격을 막습니다. 면접에서 "GET과 POST의 차이"를 물으면 "데이터가 주소에 실리느냐"보다 "상태를 바꾸느냐"를 먼저 말하는 것이 좋습니다.
한 줄 요약08은 07을 복사해 패키지·스캔 범위를 com.hk.ansboard로 바꾸고, 로깅(SLF4J·Logback)·AOP(AspectJ)·트랜잭션(spring-tx) 라이브러리를 더했다. Service·DAO는 이번엔 인터페이스 없이 클래스 하나로 만들었다.
쉽게 말하면같은 설계도로 새 건물을 짓되, 이번엔 CCTV(로그)와 공사 중 보조 장비(AOP·트랜잭션) 배선을 미리 깔아 둔 셈입니다. 건물 주소(패키지)가 바뀌었으니 "우리 건물 직원 명단을 어디서 찾을지"(component-scan)도 새 주소로 고쳐 줘야 스프링이 직원(빈)을 찾아냅니다.
① 패키지와 스캔 범위 — 복사했으면 같이 바꾼다
07은 com.hk.board, 08은 com.hk.ansboard입니다. 자바 파일의 패키지만 바꾸고 component-scan의 base-package를 그대로 두면 스프링이 @Controller·@Service를 못 찾아 빈이 하나도 등록되지 않습니다(404나 주입 실패로 나타남). 매퍼 XML의 namespace(com.hk.ansboard.dao)도 같은 이유로 함께 바꿨습니다. 08은 나중에 스캔을 servlet은 controller, root는 service·dao로 나눈다 → web-59.
③ 인터페이스 없이 클래스 하나 — @Service AnsService · @Repository AnsDao
07은 "인터페이스 + 구현 클래스"로 계약과 구현을 나눴고(web-42), 08은 단순함을 택해 클래스 하나로 갑니다. 인터페이스가 없어도 주입은 잘 됩니다 — 주입은 타입이 맞는 빈을 찾을 뿐이니까요. @Repository·@Service는 @Component와 똑같이 스캔 대상이고, 이름이 역할을 드러낼 뿐 등록 방식은 같습니다.
④ DTO에 붙은 다섯 칸 — refer · step · depth · readCount · delflag
@Repository // ← @Component 계열: 스캔되면 빈으로 등록
public class AnsDao {
@Autowired //타입으로 찾아서 주입하는 기능 // ← 기본: 타입으로 찾는다
// @Qualifier("sqlSessionTemplate") //이름으로 구별해서 주입
// @Resource() //별도 라이브러리 추가(이름으로 매칭, 이름이 없으면 타입으로 매칭)
private SqlSessionTemplate sqlSession;
private String namespace="com.hk.ansboard.dao."; // ← 매퍼 namespace 도 새 패키지로
52번 정리 — 핵심 정리
프로젝트를 복사하면 패키지 · component-scan · 매퍼 namespace를 함께 바꿔야 빈과 SQL이 제대로 찾아진다.
@Autowired — 타입이 맞는 빈을 찾아 넣는다. 같은 타입 빈이 둘 이상이면 어느 것을 넣을지 몰라 오류가 난다.
@Qualifier("이름") — @Autowired와 함께 써서 같은 타입 중 이름으로 하나를 고른다.
@Resource — 이름으로 먼저, 없으면 타입으로 찾는다. 스프링이 아니라 Jakarta 표준 애너테이션이라 라이브러리를 따로 넣어야 한다.
@Repository·@Service·@Controller는 모두 @Component 계열 — 등록 방식은 같고 이름이 역할을 말해 준다.
인터페이스가 없어도 동작한다. 07은 "계약과 구현 분리"를, 08은 "단순함"을 택한 것이다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
07을 복사해 패키지 이름만 com.hk.ansboard로 바꿨더니 모든 주소가 404다. 가장 먼저 볼 곳은?
servlet-context.xml의 component-scan base-package입니다. 아직 com.hk.board를 가리키고 있으면 스프링은 새 패키지의 @Controller를 아예 찾지 못해 요청을 받을 메서드가 없으니 404가 납니다. 코드가 멀쩡해 보여도 "스프링이 그 클래스를 알고 있는가"부터 확인하는 습관이 필요합니다.
SqlSessionTemplate 빈이 두 개(서로 다른 DB용) 등록돼 있다면 @Autowired만으로 될까?
안 됩니다. 타입이 같은 후보가 둘이라 스프링이 어느 것을 넣을지 정하지 못하고 시작할 때 오류를 냅니다. 이럴 때 @Qualifier("sqlSessionTemplate")처럼 빈 이름을 적어 하나를 고르거나, 이름으로 먼저 찾는 @Resource를 씁니다.
💼 실무·코딩테스트에서는"기존 프로젝트를 복사해 새 프로젝트 시작"은 실무에서도 흔한데, 패키지·스캔 범위·설정 파일 경로 중 하나를 빠뜨려 반나절을 날리는 일이 자주 생깁니다. 면접에서는 "@Autowired와 @Resource의 차이", "같은 타입 빈이 여럿이면?"(@Qualifier·@Primary)이 단골이에요. 요즘은 필드 주입보다 생성자 주입을 권장합니다 → web-61.
53
로그를 남기는 법 — SLF4J는 창구, 출력은 Logback, 그리고 log4j.xml이 안 읽히는 이유
SLF4JLogback로그 레벨logback.xmllog4j.xml
한 줄 요약코드는 SLF4J(창구)에 대고 로그를 남기고, 실제 출력은 클래스패스에 있는 구현체가 한다. 08의 구현체는 Logback이라 logback.xml을 읽는다 — 지금 프로젝트에 있는 log4j.xml은 읽히지 않는다.
쉽게 말하면SLF4J는 "택배 접수 창구"입니다. 어느 택배사(Log4j·Logback)가 실제로 배달할지는 창구 뒤에 누가 앉아 있느냐로 정해지고, 배송 규칙표(설정 파일)도 그 회사 양식이어야 합니다. 08은 창구 뒤에 Logback이 앉아 있는데 규칙표는 Log4j 양식으로 써 둔 상태예요.
① System.out.println 대신 로거를 쓰는 이유
로그마다 수준(TRACE < DEBUG < INFO < WARN < ERROR)이 붙어서, 코드를 고치지 않고 설정만 바꿔 "개발 중엔 DEBUG까지, 운영에선 INFO부터"처럼 걸러 낼 수 있습니다. 출력할 곳(콘솔·파일)과 모양(시간·클래스명)도 설정으로 정해요. {} 자리 표시는 문자열을 미리 이어 붙이지 않아서, 꺼진 수준의 로그는 비용이 거의 들지 않습니다.
② 창구와 구현체 — "한 줄로 맞아야" 한다
수업 주석에는 "log4j(실제 출력 작업)"라고 적혀 있고 설정 파일도 log4j.xml(Log4j 1.x 형식)입니다. 그런데 pom.xml에 넣은 구현체는 logback-classic이고, Log4j 라이브러리는 없습니다. "코드가 부르는 창구(SLF4J)" · "실제 구현체" · "설정 파일 형식" 세 가지가 한 줄로 맞아야 설정이 먹힙니다. Log4j를 쓰고 싶다면 pom에서 Logback을 빼고 SLF4J–Log4j 연결 라이브러리를 넣어야 하고, 참고로 Log4j 1.x는 2015년에 지원이 끝나 새 프로젝트에는 쓰지 않습니다.
③ 로그 파일 경로의 ${user.dir}
log4j.xml의 파일 경로 ${user.dir}/src/main/resources/log/log.log에서 user.dir은 프로그램을 실행한 폴더입니다. 톰캣을 어디서 띄웠느냐에 따라 달라지고 프로젝트 폴더가 아닐 수 있어요. Controller에서 user.dir을 DEBUG로 찍어 본 것이 그 확인입니다.
JAVAAnsController.java — 로거 선언과 사용
//log 출력을 위한 선언: slf4j(로그출력할 준비작업), log4j(실제 출력 작업)
private static final Logger logger=
LoggerFactory.getLogger(AnsController.class); // ← org.slf4j.Logger: 창구만 안다
@RequestMapping(value = "/home.do", method = RequestMethod.GET)
public String home() {
logger.info("HOME페이지로 이동");
logger.debug("Working Directory:{}", System.getProperty("user.dir")); // ← {} 자리에 값이 들어간다
return "home";
}
실행 결과08과 같은 라이브러리·같은 log4j.xml 위치로 로거를 만들어 봤다 먼저 예측 → 펼쳐서 확인
① 실제 로깅 구현체 : ch.qos.logback.classic.LoggerContext
② 클래스패스에 log4j.xml 있음? true
클래스패스에 logback.xml 있음? false
③ logback 설정 기록:
Could NOT find resource [logback-test.xml]
Could NOT find resource [logback.xml]
Setting up default configuration.
④ debug 켜짐? true / root 레벨 = DEBUG
⑤ 로그 두 줄 출력 ↓
15:46:13.438 [main] INFO com.hk.ansboard.controller.AnsController -- HOME페이지로 이동
15:46:13.439 [main] DEBUG com.hk.ansboard.controller.AnsController -- Working Directory:…
⑥ log4j.xml의 날짜별 파일 생겼나? false
Logback은 logback-test.xml → logback.xml 순으로 찾다가 없으면 기본 설정(콘솔, 전체 DEBUG)으로 동작합니다. 옆에 있는 log4j.xml은 쳐다보지도 않아요.
그래서 ① 출력 모양이 log4j.xml에 적은 %-5p: %c - %m%n이 아니라 Logback 기본 형식이고, ② 스프링 내부 로그까지 DEBUG로 쏟아지며, ③ 날짜별 로그 파일은 만들어지지 않습니다. 실제로 워크스페이스의 src/main/resources/log/ 폴더(9/29 생성)는 지금까지 비어 있습니다.
XMLlogback.xml — log4j.xml과 같은 뜻의 예시 (복습용, 수업 소스에는 넣지 않았다)
<!-- 위치: src/main/resources/logback.xml -->
<configuration>
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder><pattern>%-5level: %logger - %msg%n</pattern></encoder>
</appender>
<logger name="com.hk.ansboard" level="DEBUG"/> <!-- ← 내 코드는 DEBUG 까지 -->
<logger name="org.springframework" level="INFO"/> <!-- ← 스프링은 INFO 부터 -->
<root level="INFO"><appender-ref ref="console"/></root>
</configuration>
53번 정리 — 핵심 정리
SLF4J = 로그 API(창구). 출력은 클래스패스의 구현체(Logback 등)가 한다.
설정 파일은 구현체 양식이어야 한다 — Logback은 logback.xml, Log4j 1.x는 log4j.xml.
설정을 못 찾은 Logback은 콘솔·전체 DEBUG로 동작한다. 로그가 유난히 많으면 설정이 안 읽힌 신호다.
수준: TRACE < DEBUG < INFO < WARN < ERROR — 설정한 수준 이상만 찍힌다.
{} 자리 표시를 쓰고 문자열을 +로 이어 붙이지 않는다.
${user.dir}은 실행한 폴더라 프로젝트 폴더라는 보장이 없다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
그 설정 파일이 아예 읽히지 않았기 때문입니다. 클래스패스에 있는 구현체는 Logback이고, Logback은 logback.xml만 찾습니다. 못 찾으면 기본 설정(전체 DEBUG)으로 돌아가니 스프링 내부 로그까지 전부 나오죠. 해결은 구현체에 맞는 logback.xml을 만들거나, 구현체를 Log4j 쪽으로 바꾸는 것입니다.
출력 결과는 같지만, 앞의 것은 DEBUG가 꺼져 있어도 문자열을 먼저 이어 붙입니다(인자를 만드는 게 메서드 호출보다 먼저니까요). 뒤의 것은 수준이 켜져 있을 때만{}에 값을 채워 넣어서, 꺼진 로그는 거의 비용이 없습니다. 로그가 수천 번 불리는 곳에서 차이가 납니다.
💼 실무·코딩테스트에서는운영 서버에서는 디버거를 붙일 수 없어서 로그가 유일한 단서입니다. 그래서 실무에서는 수준을 패키지별로 나누고, 날짜별 파일로 남기고, 오래된 파일은 지우는 설정을 꼭 합니다. 면접에서는 "SLF4J를 쓰는 이유"(구현체를 바꿔도 코드는 그대로)와, 2021년 Log4j 2의 Log4Shell 취약점처럼 로깅 라이브러리도 보안 업데이트 대상이라는 점이 자주 나옵니다.
54
답글을 한 테이블에 줄 세우기 — refer · step · depth와 페이지 나누기
답변형 게시판refer·step·depthROW_NUMBER페이징MAX+1
한 줄 요약refer는 "어느 글 묶음인가", step은 "묶음 안에서 몇 번째 줄인가", depth는 "몇 칸 들여쓰는가"다. 목록은 refer 내림차순 · step 오름차순으로 세우고, ROW_NUMBER()로 줄 번호를 붙인 뒤 ceil(rn/10) = pnum으로 10개씩 자른다.
쉽게 말하면원글과 그 답글들은 같은 반(refer)이고, 반 안에서 출석 번호(step)대로 서며, 답글의 답글일수록 한 칸씩 더 안쪽(depth)에 섭니다. 새 반은 늘 맨 앞에 서고요. 페이지 나누기는 줄 선 사람들에게 1번부터 번호표를 나눠 준 다음 "1~10번은 1쪽, 11~20번은 2쪽"으로 끊는 것입니다.
① 세 컬럼의 뜻 — 새 글일 때의 값
refer — 글 묶음 번호. 원글과 그 모든 답글이 같은 값이고, 새 글은 MAX(refer) + 1(가장 새로운 묶음)을 받습니다. step — 묶음 안의 줄 순서. 새 글은 0(묶음의 맨 위). depth — 들여쓰기 단계. 새 글은 0(원글). 답글이 이 값들을 어떻게 받는지는 → web-58.
② 새 글 번호 — nvl(MAX(refer),0)+1과 그 한계
글이 하나도 없으면 MAX(refer)가 NULL이라 nvl(…, 0)으로 0을 만든 뒤 1을 더합니다. 이렇게 "지금 가장 큰 값 + 1"로 번호를 매기는 방식은 두 사람이 동시에 글을 쓰면 같은 번호가 나올 수 있습니다 — 수업용으로 단순하게 만든 방식이라는 점만 기억해 두세요.
③ 줄 세우기와 페이지 나누기 — "먼저 번호, 그다음 거르기"
ORDER BY refer DESC, step ASC — 새 묶음이 위로, 묶음 안에서는 원글(step 0) 다음에 답글이 순서대로 옵니다. ROW_NUMBER() OVER(…)는 그 순서대로 1, 2, 3… 줄 번호(rn)를 붙여요. 안쪽 쿼리에서 먼저 번호를 붙여야 바깥 쿼리의 WHERE에서 그 번호로 거를 수 있습니다(같은 단계의 WHERE에서는 아직 rn이 없으니까요). pnum은 Controller에서 @RequestParam(value="pnum", defaultValue="1")로 받아 없으면 1쪽입니다.
SQLBoardMapper.xml — boardInsert (새 글 저장)
INSERT INTO answerboard
VALUES(NULL,#{id},#{title},#{content},SYSDATE(),
(SELECT nvl(MAX(refer),0)+1 FROM answerboard) -- ← 새 묶음 번호 (글이 없으면 1)
,0,0,0,'N') -- ← step, depth, readcount, delflag
SELECT rn, seq, id, title, content, regdate, refer, step, depth, readcount, delflag
FROM (
SELECT ROW_NUMBER() OVER(ORDER BY refer DESC, step ASC) AS rn, -- ← ① 줄 세우고 번호
seq, id, title, content, regdate, refer, step, depth, readcount, delflag
FROM answerboard
) a
WHERE ceil(rn/10) = #{pnum} -- ← ② 번호로 쪽 거르기
-- 페이지 개수
select ceil(count(*)/10) -- ← 23개면 2.3 → 올림해서 3쪽
from answerboard
실행 결과rn이 몇이면 몇 쪽에 나오나 — ceil(rn/10) 먼저 예측 → 펼쳐서 확인
목록 순서는 refer DESC, step ASC — 새 묶음이 위, 묶음 안은 위에서 아래로.
페이지 나누기는 "먼저 번호 붙이고(ROW_NUMBER) → 바깥에서 번호로 거르기".
ceil(rn/10) = pnum — rn 1~10은 1쪽, 11~20은 2쪽.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
ORDER BY를 refer DESC 하나만 쓰면 목록이 어떻게 깨지나?
같은 묶음 안의 순서가 정해지지 않습니다. refer가 같은 원글과 답글들 사이에서는 DB가 마음대로 순서를 정해도 되니, 답글이 원글보다 위에 오거나 답글의 답글이 엉뚱한 자리에 끼일 수 있어요. step ASC가 묶음 안의 줄 순서를 보장하는 두 번째 기준입니다.
WHERE ceil(ROW_NUMBER() OVER(…)/10) = 1 처럼 서브쿼리 없이 한 번에 쓰면 안 되나?
안 됩니다. SQL은 WHERE를 먼저 처리하고 그다음에 SELECT의 윈도 함수(ROW_NUMBER)를 계산하기 때문에, WHERE 시점에는 줄 번호가 아직 없습니다. 그래서 안쪽 쿼리에서 번호를 붙여 하나의 표로 만든 뒤 바깥에서 거르는 것입니다.
💼 실무·코딩테스트에서는ROW_NUMBER()로 번호 붙이고 바깥에서 거르기는 페이징뿐 아니라 "그룹별 상위 N개"(PARTITION BY) 문제로 SQL 코딩테스트에 자주 나옵니다. 실무 페이징은 LIMIT 10 OFFSET 20도 많이 쓰고, 데이터가 아주 많으면 "마지막으로 본 번호 다음부터" 가져오는 방식을 쓰기도 해요. 그리고 번호를 MAX+1로 매기지 않고 AUTO_INCREMENT나 시퀀스에 맡기는 것이 동시성 문제를 피하는 기본입니다.
55
지우지 않고 표시만 — 논리 삭제, 조회수와 redirect
논리 삭제delflag조회수redirectPOST 삭제
한 줄 요약삭제는 DELETE 대신 delflag를 'Y'로 바꾸는 UPDATE다(논리 삭제). 조회수는 목록에서 들어올 때만 올리고 곧바로 redirect 해서, 새로고침으로 다시 오르지 않게 한다.
쉽게 말하면게시글을 파쇄기에 넣는 대신 "삭제됨" 도장만 찍는 것입니다. 답글이 달린 원글을 통째로 없애면 그 아래 답글들의 자리가 무너지기 때문이죠. 조회수는 "입장권을 찢고(조회수 +1) 다른 문으로 안내"하는 방식이라, 안에서 새로고침을 해도 입장권을 또 찢지 않습니다.
① 논리 삭제 — 행은 남기고 표시만 바꾼다
07의 다중 삭제는 DELETE였습니다(web-45). 08은 같은 foreach로 UPDATE … SET delflag='Y'를 하고, 그래서 DAO도 sqlSession.update를 부릅니다. 목록 JSP는 delflag가 Y인 글의 제목 대신 "---삭제된 글입니다.---"를 보여 주고 상세 링크를 걸지 않아요. 행이 남아 있으므로 refer·step 순서가 깨지지 않습니다.
② 조회수 — review=y일 때만 올리고 redirect
목록의 링크는 boardDetail.do?seq=…&review=y&pnum=…입니다. Controller는 review가 "y"면 조회수만 올리고 review가 없는 주소로 redirect 합니다. 그래서 주소창에는 boardDetail.do?seq=…&pnum=…만 남고, 여기서 새로고침하거나 글을 수정한 뒤 돌아와도 조회수가 다시 오르지 않습니다. 저장 뒤 redirect(PRG)와 같은 생각을 조회수에 적용한 것이고, 주석처럼 요청이 두 번 오가는 비용은 있습니다.
③ 상세 화면의 삭제 버튼 — GET 링크에서 POST 폼으로
07은 location.href='mulDel.do?seq='+seq(GET)였는데(08 소스에도 주석으로 남아 있음), 08은 숨은 폼에 seq를 넣고 POST로 제출합니다. Controller의 mulDel도 POST만 받아요. 삭제는 POST를 그대로 실천한 것입니다.
SQLBoardMapper.xml — mulDel (논리 삭제) · readCount
<!-- 동적쿼리 사용시 파라미터는 map에 담아서 전달하자 -->
<update id="mulDel" parameterType="Map">
update answerboard set delflag='Y' <!-- ← DELETE 가 아니라 UPDATE -->
where seq in
<foreach collection="seqs" item="seq" open="(" close=")" separator=",">
#{seq}
</foreach>
</update>
<!-- 조회수 올리기 -->
<update id="readCount" parameterType="Integer">
update answerboard set readcount = readcount+1
where seq = #{seq}
</update>
@RequestMapping(value = "/boardDetail.do", method = RequestMethod.GET)
public String boardDetail(@RequestParam("seq")int seq,
@RequestParam(value="review",required = false)String review,
@RequestParam("pnum") String pnum,
Model model) {
// y값이 있는 경우가 글목록에서 요청된 경우
if(review!=null&&review.equals("y")) {
ansService.readCount(seq);//조회수 올리기 // ← ① 한 번만 +1
//한번요청에 2번 통신을 하게 되서 성능은 저하될 수 있음
return "redirect:boardDetail.do?seq="+seq+"&pnum="+pnum; // ← ② review 없이 다시 오게
}else {
AnsDto dto=ansService.boardDetail(seq); // ← ③ 실제 화면은 여기서
model.addAttribute("dto", dto);
model.addAttribute("pnum", pnum);
return "boardDetail";
}
}
JSPboardDetail.jsp — 숨은 폼으로 POST 삭제
<form id="deleteForm" action="mulDel.do" method="post">
<input type="hidden" id="deleteSeq" name="seq" value=""/> <!-- ← JS 가 채운다 -->
<input type="hidden" name="pnum" value="${pnum}"/>
</form>
...
function boardDelete(seq){
if(confirm("정말 삭제하겠습니까?")){
// location.href='mulDel.do?seq='+seq; // ← 07 방식(GET)
document.getElementById("deleteSeq").value=seq;
//js에서 submit 하기
document.getElementById("deleteForm").submit(); // ← POST 로 제출
}
}
55번 정리 — 핵심 정리
논리 삭제 = 행은 두고 표시 컬럼(delflag)만 바꾼다. 답글 구조·기록 보존에 유리하다.
논리 삭제를 쓰면 조회할 때마다 "삭제된 글을 어떻게 보여 줄지"를 챙겨야 한다.
조회수는 목록에서 온 요청(review=y)에서만 올리고, 바로 redirect 한다.
상태를 바꾸는 요청(조회수 증가 포함) 뒤에는 redirect로 새로고침을 안전하게 만든다.
삭제처럼 데이터를 바꾸는 요청은 GET 링크가 아니라 POST 폼으로 보낸다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
redirect 없이 조회수를 올리고 바로 "boardDetail"을 돌려주면 무엇이 문제인가?
주소창에 review=y가 붙은 주소가 그대로 남습니다. 그 상태로 새로고침하면 같은 요청이 다시 가서 조회수가 또 오르죠. 글을 수정하고 그 주소로 돌아와도 마찬가지입니다. redirect로 review가 빠진 주소로 바꿔 두면 이후의 새로고침은 "보기만" 하는 요청이 됩니다.
삭제(delflag='Y')된 글을 주소창에 boardDetail.do?seq=그번호&pnum=1로 직접 치면 어떻게 될까?
내용이 그대로 보입니다(소스로 따진 결과). 목록 JSP는 삭제된 글에 링크를 걸지 않지만, 상세 조회 SQL(boardDetail)에는 delflag 조건이 없기 때문입니다. 논리 삭제를 쓸 때 "조회하는 모든 곳에서 삭제 표시를 확인해야 한다"는 부담이 바로 이것이에요.
💼 실무·코딩테스트에서는실무에서는 논리 삭제(soft delete)를 매우 많이 씁니다 — 실수로 지운 데이터 복구, 감사 기록 때문이죠. 보통 delflag 대신 deleted_at(삭제 시각)을 두기도 해요. 다만 조회 쿼리마다 조건을 빠뜨리는 버그가 단골이고, 개인정보처럼 정말 지워야 하는 데이터는 물리 삭제가 필요합니다. 조회수는 실제 서비스에서 같은 사람이 여러 번 눌러도 한 번만 세도록 세션·쿠키로 막는 경우가 많습니다.
56
화면 공통 부분 나누기 — jsp:include와 Bootstrap
jsp:includeinclude 지시어BootstrapViewResolver
한 줄 요약모든 화면에 반복되는 머리(메뉴)와 꼬리(저작권)를 header.jsp·footer.jsp로 빼고 <jsp:include>로 불러온다. 모양은 Bootstrap 클래스로 입혔다. 조각 파일에는 필요한 태그만 두고, 뷰 이름에는 확장자를 붙이지 않는다.
쉽게 말하면편지지 윗부분 로고와 아랫부분 주소를 매번 손으로 그리지 않고 도장으로 찍는 것입니다. 로고를 바꾸고 싶으면 도장 하나만 새로 파면 모든 편지가 바뀌죠. Bootstrap은 미리 디자인된 스티커 모음이라, btn btn-primary 같은 이름표만 붙이면 버튼 모양이 입혀집니다.
① 동적 include와 정적 include
<jsp:include page>(동적) — 요청할 때마다 그 JSP를 따로 실행해 결과를 끼워 넣습니다. 반면 <%@ include file %>(정적)는 번역 단계에서 소스를 그대로 붙여 한 JSP로 만들어요. 공통 화면처럼 독립적인 조각은 보통 동적 include를 씁니다. 정적 include는 변수 선언처럼 "원래 한 파일이었던 것처럼" 섞여야 할 때 씁니다.
② 조각 파일이 완전한 문서라서 생기는 일
지금 header.jsp·footer.jsp는 각각 <!DOCTYPE html><html>…<body>…</html>을 가진 완전한 문서입니다. 그래서 목록 화면의 결과 HTML에는 <html>·<body>가 세 번씩 나옵니다. 브라우저가 너그럽게 처리해 보이긴 하지만, 조각 파일에는 <nav>…</nav>처럼 필요한 부분만 두는 편이 맞습니다(소스를 읽고 따진 결과).
③ Bootstrap — 클래스 이름만 붙이면 되는 CSS 모음
table table-striped(줄무늬 표), btn btn-primary(파란 버튼), form-control(입력칸)처럼 클래스 이름만 붙이면 모양이 입혀집니다. 08은 header.jsp에서 CDN 주소로 Bootstrap 5.3.8의 CSS·JS를 불러오고, 그 header를 모든 화면이 include 하니 한 곳에서 불러온 스타일이 전 화면에 적용됩니다.
④ 뷰 이름에는 확장자를 붙이지 않는다
return "error.jsp";는 뷰 이름으로 해석되어 ViewResolver가 /WEB-INF/views/error.jsp.jsp를 찾습니다. error.jsp를 views에 두고 return "error";로 써야 해요(redirect:로는 WEB-INF 안을 열 수 없다 → 자세히).
JSPboardList.jsp — 머리·꼬리 include와 Bootstrap 클래스 (중간 생략)
JSPheader.jsp — Bootstrap을 불러오는 곳 (integrity 값 생략)
<!DOCTYPE html> <!-- ← 조각인데 문서 전체를 갖고 있다 -->
<html>
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" …>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" …></script>
...
</head>
<body>
<nav class="navbar navbar-expand-lg bg-primary-subtle"> <!-- ← 실제로 필요한 건 이 nav 뿐 -->
...
</nav>
</body>
</html>
<jsp:include>는 실행 결과를, <%@ include %>는 소스를 끼워 넣는다.
조각 파일에는 필요한 태그만 — 완전한 문서를 넣으면 <html>이 여러 번 나온다.
Bootstrap은 클래스 이름만 붙이면 모양이 입혀지는 CSS 모음이다.
뷰 이름에는 확장자를 붙이지 않는다 — ViewResolver가 .jsp를 붙인다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
글 저장이 실패했을 때 return "error.jsp";는 어떤 화면을 보여 줄까?
404 오류 화면이 나옵니다. ViewResolver가 앞뒤에 /WEB-INF/views/와 .jsp를 붙여 error.jsp.jsp를 찾기 때문이고, 게다가 08의 views 폴더에는 error.jsp 자체가 없습니다. 평소엔 저장이 성공하니 실패 경로에서만 드러나는 버그라 더 늦게 발견됩니다.
header.jsp에서 변수 하나를 선언하고 boardList.jsp에서 그 변수를 쓰려면 어느 include를 써야 하나?
정적 include(<%@ include file %>)입니다. 정적 include는 소스를 붙여 한 JSP(한 서블릿)로 만들므로 변수가 공유되지만, 동적 include는 따로 실행된 결과(HTML)만 받아 오니 변수가 넘어오지 않습니다. 화면 조각처럼 독립적인 것은 동적, 섞여야 하는 코드는 정적입니다.
💼 실무·코딩테스트에서는실무에서는 include를 한 단계 더 발전시킨 레이아웃 도구(Tiles, Thymeleaf Layout 등)로 "머리·메뉴·본문·꼬리" 틀을 정해 두고 본문만 갈아 끼웁니다. React·Vue 같은 프론트엔드에서는 이것이 컴포넌트가 되죠. 생각은 같습니다 — 반복되는 것은 한 곳에. 면접에서는 "jsp:include와 include 지시어의 차이"가 JSP 기본 질문으로 나옵니다.
정리 작업 메모
2026-09-30 기준으로 남아 있던 것: 페이지 번호(목록 SQL은 10개씩 자르지만 화면에는 "--페이지 번호가 들어갈 부분--" 자리만 있어 ?pnum=2를 직접 붙여야 했다), 답글 달기(Mapper에 주석 자리만), 실패 화면(Controller의 return "error.jsp";, 08 views에 error.jsp 없음).
페이지 번호와 답글 달기는 41일차에 채워졌다: 페이지 번호 · 답글 달기. error.jsp 문제는 10/2 기준으로도 그대로다(파일로 따진 결과).
57
페이지 번호 — Paging 유틸과 pnum 들고 다니기
페이징Paging 유틸정수 나눗셈pnum 유지Bootstrap pagination
한 줄 요약페이지 번호를 5개씩 묶어 보여 준다. 현재 쪽이 속한 묶음의 끝 번호를 먼저 구하고, 거기서 시작·끝·이전·다음을 계산한다. 화면을 옮겨 다녀도 보던 쪽을 잃지 않도록 pnum을 계속 넘긴다.
쉽게 말하면엘리베이터 버튼판이 5층씩 한 장이라고 생각해 보세요. 8층에 있으면 6~10층 판이 보이고, "이전"은 5층, "다음"은 11층으로 갑니다. 그리고 어느 층에서 내려 볼일을 보든, 돌아올 때는 원래 있던 층(pnum)으로 데려다 주는 것이 "pnum 들고 다니기"입니다.
① 묶음의 끝 번호부터 — 자바의 정수 나눗셈
(pNumber-1)/pageRange+1은 "몇 번째 묶음인가"입니다. 자바의 int / int는 소수점을 버리므로 1~5쪽은 1, 6~10쪽은 2가 되고, 여기에 pageRange를 곱하면 묶음의 끝 번호입니다. 시작은 끝 − (범위 − 1), 끝은 총 페이지를 넘지 않게 자릅니다. 첫 줄의 삼항 연산자는 두 갈래가 같은 값이라(첫 묶음이면 1 × 5 = 5) ((pNumber-1)/pageRange+1)*pageRange 하나로 충분합니다.
② SQL 쪽은 반대 — MariaDB의 / 는 소수 나눗셈
페이지 수를 구하는 ceil(count(*)/10)은 MariaDB가 /를 소수 나눗셈으로 하므로 ceil로 올려야 23개 → 3쪽이 됩니다(web-54). 같은 /인데 자바는 버리고, DB는 소수를 남긴다는 점을 구분하세요.
③ pnum 들고 다니기 — 그리고 필수 파라미터의 함정
목록의 글쓰기 버튼은 boardInsertForm.do?pnum=${pnum}, 상세 링크는 …&review=y&pnum=${pnum}, 상세·삭제·답글 폼에는 <input type="hidden" name="pnum">가 있습니다. Controller는 끝나면 redirect:boardList.do?pnum=처럼 같은 쪽으로 돌려보냅니다. 단 이 메서드들의 @RequestParam("pnum")은 필수라서, pnum 없이 boardDetail.do?seq=3을 직접 열면 400 오류가 납니다(소스로 따진 결과). 새 글 저장만은 redirect:boardList.do로 1쪽에 갑니다 — 새 글은 refer가 가장 커서 1쪽 맨 위에 있기 때문입니다.
JAVAutil/Paging.java — pagingValue (수업 주석 일부 생략)
public static Map<String, Integer> pagingValue(int pcount,String pNum,int pageRange){
Map<String, Integer> map=new HashMap<String, Integer>();
int pNumber=Integer.parseInt(pNum);
// 현재 쪽이 속한 묶음의 마지막 번호 (8쪽 → 10)
int pageEndNum=((pNumber-1)/pageRange+1)==1?pageRange:((pNumber-1)/pageRange+1)*pageRange; // ← 핵심
int prePageNum=pageEndNum-pageRange==0?1:pageEndNum-pageRange; // ← 앞 묶음의 끝 (첫 묶음이면 1)
int nextPageNum=pageEndNum>=pcount?pcount:pageEndNum+1; // ← 다음 묶음의 처음 (마지막 묶음이면 pcount)
int startPage=pageEndNum-(pageRange-1);//현재페이지번호가 8일경우 10-(5-1)= 6
int endPage=pageEndNum>pcount?pcount:pageEndNum; // ← 총 페이지를 넘지 않게
map.put("prePageNum", prePageNum);
map.put("nextPageNum", nextPageNum);
map.put("startPage", startPage);
map.put("endPage", endPage);
return map;
}
실행 결과pagingValue(pcount, pnum, 5)를 여러 경우로 호출 먼저 예측 → 펼쳐서 확인
${pnum==i} — pnum은 요청에서 온 문자열 "8", i는 숫자입니다. EL은 한쪽이 숫자면 둘 다 숫자로 바꿔 비교하므로 현재 쪽에 active가 붙습니다. JSP 안에는 단계별로 써 본 흔적(1부터 끝까지 전부 → pre/next 글자 링크 → Bootstrap)이 주석으로 남아 있습니다.
57번 정리 — 핵심 정리
묶음 끝 = ((현재-1)/범위 + 1) × 범위, 시작 = 끝 − (범위 − 1).
끝 번호와 "다음"은 총 페이지 수를 넘지 않게 자른다.
정수 나눗셈(자바)과 소수 나눗셈(MariaDB의 /)을 구분한다.
목록으로 돌아오는 모든 길(링크·폼·redirect)에 pnum을 실어 보낸다.
@RequestParam("pnum")이 필수면 pnum 없는 주소는 400 — defaultValue를 주면 막을 수 있다.
EL의 ==는 문자열 "8"과 숫자 8을 숫자로 맞춰 비교한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
pcount=14, pnum=12일 때 이전·시작~끝·다음은?
묶음 번호는 (12-1)/5+1 = 2+1 = 3, 끝 번호는 3×5 = 15입니다. 그래서 이전 = 15−5 = 10, 시작 = 15−4 = 11, 끝은 15가 14를 넘으므로 14, 다음은 15 >= 14라 14. 즉 10 / 11~14 / 14입니다. 11/5가 2.2가 아니라 2가 되는 정수 나눗셈이 핵심이에요.
3쪽에서 글을 보고 "글목록" 버튼을 눌렀는데 1쪽으로 돌아간다면 무엇을 빠뜨린 것인가?
pnum을 어딘가에서 안 넘긴 것입니다. 목록 링크 → 상세 Controller → model.addAttribute("pnum") → 상세 JSP의 버튼 boardList.do?pnum=${pnum}까지 한 고리라도 빠지면 pnum이 사라지고, 목록 Controller의 defaultValue="1"이 1쪽을 보여 줍니다. HTTP는 이전 요청을 기억하지 않으므로 상태는 이렇게 직접 들고 다녀야 합니다.
💼 실무·코딩테스트에서는페이징 계산은 정수 나눗셈·올림을 정확히 다루는 연습이라 코딩테스트의 "총 몇 묶음인가", "몇 번째 그룹인가" 문제와 같은 식입니다((n + k - 1) / k로 올림하는 공식도 같이 익혀 두세요). 실무에서는 이런 계산을 Spring Data의 Pageable 같은 도구가 대신해 주지만, "목록으로 돌아올 때 보던 쪽·검색어를 유지하라"는 요구는 거의 모든 관리 화면에 따라붙습니다.
58
답글 달기 — 아래 글들 step을 밀고 그 자리에 끼워 넣기
답변형 게시판step 밀기CDATA서브쿼리depth 들여쓰기
한 줄 요약답글은 부모와 같은 refer, 부모 step + 1, 부모 depth + 1로 저장한다. 그 전에 같은 묶음에서 부모보다 아래에 있는 글의 step을 모두 1씩 밀어 자리를 만든다. 즉 답글 하나 = UPDATE 한 번 + INSERT 한 번이다.
쉽게 말하면줄 선 사람들 사이에 끼어들려면, 들어갈 자리 뒤의 사람들이 한 걸음씩 물러나 줘야 합니다(step 밀기). 그다음 빈자리에 들어가면서 한 칸 안쪽으로 서는 거죠(depth + 1). 물러나기만 하고 아무도 안 들어오면 줄에 구멍이 생기니, 두 동작은 꼭 같이 일어나야 합니다.
① #{seq}는 부모 글 번호다
상세 화면의 답글 폼에 <input type="hidden" name="seq" value="${dto.seq}"/>가 있어서, 한 AnsDto에 부모 seq와 새 답글의 id·title·content가 함께 담겨 옵니다. 그래서 두 쿼리 모두 WHERE seq=#{seq}로 부모의 refer·step·depth를 서브쿼리로 읽어 옵니다.
② 순서가 중요하다 — 먼저 밀고, 그다음 넣기
① UPDATE: 부모와 같은 refer이면서 부모 step보다 큰 글들의 step + 1 → 부모 바로 아래(step + 1) 자리가 빈다. ② INSERT: refer는 부모와 같게, step·depth는 부모 + 1로 그 빈자리에 넣는다. 두 쿼리는 한 묶음이어야 해서, 42일차에 @Transactional을 겁니다 → web-59.
③ CDATA와 자기 테이블 서브쿼리
<![CDATA[ … ]]>는 XML 안에서 >·<를 SQL 기호 그대로 쓰기 위한 감싸개입니다. 감싸지 않으면 <는 태그 시작으로 읽힙니다. 또 이 UPDATE는 자기 테이블을 서브쿼리로 다시 읽습니다. 이 PC의 MariaDB(12.3)는 10.3.2부터 이를 허용하지만, MySQL에서는 같은 문장이 1093 오류(You can't specify target table … for update in FROM clause)를 냅니다(문서 기준, 이번에 MySQL로 실행해 보지는 않았다).
<update id="replyUpdate" parameterType="AnsDto">
<![CDATA[
UPDATE answerboard SET step = step+1
WHERE refer=(SELECT refer FROM answerboard WHERE seq=#{seq}) -- ← 부모와 같은 묶음에서
AND step > (SELECT step FROM answerboard WHERE seq=#{seq}) -- ← 부모보다 아래 글만 한 칸씩
]]>
</update>
<insert id="replyInsert" parameterType="AnsDto">
INSERT INTO answerboard
VALUES(NULL,#{id},#{title},#{content},SYSDATE(),
(SELECT refer FROM answerboard WHERE seq=#{seq}), -- ← 부모와 같은 묶음
(SELECT step FROM answerboard WHERE seq=#{seq})+1, -- ← 부모 바로 아래 줄
(SELECT depth FROM answerboard WHERE seq=#{seq})+1, 0, 'N') -- ← 한 칸 더 안쪽
</insert>
실행 결과원글 A·B를 쓰고 → A에 답글1 → A에 답글2 → 답글1에 답글 → B에 답글 먼저 예측 → 펼쳐서 확인
화면 들여쓰기는 c:forEach begin="1" end="${dto.depth}" — 원글은 0번 돈다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
UPDATE를 빼고 INSERT만 하면 목록이 어떻게 되나?
같은 묶음 안에 step이 같은 글이 둘 생깁니다. 예를 들어 A에 이미 답글1(step 1)이 있는데 답글2도 step 1로 들어가면, ORDER BY refer DESC, step ASC로는 둘의 순서가 정해지지 않아 화면 순서가 들쭉날쭉해지고, 그 뒤에 달리는 답글들의 위치 계산도 어긋납니다.
CDATA로 감싸지 않고 step > (…)의 >를 그대로 두면? <를 쓰면?
XML에서 <는 "태그 시작"이라 그대로 쓰면 매퍼 파일 자체가 깨져 서버가 시작할 때 파싱 오류가 납니다. >는 단독으로는 대개 허용되지만, 헷갈리지 않도록 비교 기호가 들어간 SQL은 CDATA로 감싸거나 <로 바꿔 쓰는 것이 안전한 습관입니다.
💼 실무·코딩테스트에서는refer·step·depth 방식은 목록을 한 번의 정렬로 뽑을 수 있다는 장점이 있지만, 답글 하나에 여러 행을 UPDATE 하므로 글이 많고 동시에 쓰는 곳에서는 부담이 됩니다. 실무 댓글은 parent_id(부모 번호)만 저장하고 재귀 쿼리(WITH RECURSIVE)나 애플리케이션에서 트리를 만드는 방식도 많이 써요. 어느 쪽이든 "여러 쿼리가 한 업무 = 트랜잭션"이라는 감각이 핵심이고, 이는 면접 단골 질문으로 이어집니다.
정리 작업 메모
위 실행 결과는 Mapper의 boardInsert·replyUpdate·replyInsert·boardList를 XML에서 그대로 꺼내 Node 내장 SQLite(메모리 DB)에서 실행한 것이다. 바꾼 것은 #{seq} → 바인딩 표기, 그리고 SQLite의 /가 정수 나눗셈이라 ceil(rn/10) → ceil(rn/10.0) 한 곳이다. SYSDATE()·nvl()은 같은 이름의 함수를 등록했다.
arrow.png는 이름과 달리 실제 내용이 JPEG(640×360)이다 — 브라우저는 내용을 보고 그리므로 표시에는 문제가 없다.
한 줄 요약@Transactional을 붙인 메서드는 하나의 작업 단위가 된다. 끝까지 성공하면 commit, 중간에 RuntimeException이 나면 그때까지 한 일을 rollback 한다. 단, 트랜잭션 설정이 있는 컨텍스트(root)에 서비스가 등록돼 있어야 걸린다.
쉽게 말하면은행 송금은 "내 통장에서 빼기"와 "상대 통장에 넣기"가 한 몸입니다. 하나만 되면 돈이 사라지죠. 답글 저장의 "step 밀기"와 "답글 넣기"도 한 몸입니다. 트랜잭션은 두 동작을 투명 비닐로 한데 묶어서, 하나라도 실패하면 비닐째 버리고 처음 상태로 돌려놓는 장치예요.
① 왜 필요한가 — 답글은 UPDATE 다음 INSERT
트랜잭션이 없으면 쿼리마다 바로 확정되므로, INSERT만 실패해도 이미 밀린 step은 그대로 남습니다. Service 주석의 "결론은 step만 올라가고 답글은 추가되지 않는 상황"이 바로 이것이고, 아래 첫 번째 실행 결과가 그 상황을 만든 것입니다.
② 무엇이 일을 하나 — 트랜잭션 매니저와 프록시
트랜잭션 매니저(DataSourceTransactionManager)가 실제로 연결의 auto commit을 끄고, 끝에 commit/rollback 합니다. MyBatis(SqlSessionTemplate)는 같은 DataSource의 그 연결을 함께 씁니다. 그리고 스프링이 AnsService를 감싼 대리 객체(AnsService$SpringCGLIB$0)를 만들어 Controller에 넣고, 대리 객체가 메서드 앞뒤에서 트랜잭션을 열고 닫습니다. 그래서 같은 클래스 안에서 this.boardReply()처럼 직접 부르면 대리 객체를 거치지 않아 걸리지 않습니다. proxy-target-class="true"는 인터페이스가 아니라 클래스를 상속해 대리 객체를 만들라는 뜻입니다(08의 Service는 인터페이스가 없다).
③ 무엇에 rollback 하나 — RuntimeException, 그리고 DataAccessException
기본은 RuntimeException과 Error. SQLException 같은 checked 예외는 기본적으로 commit 되므로 필요하면 rollbackFor = Exception.class를 적습니다. 다만 MyBatis-Spring(SqlSessionTemplate)을 쓰면 SQL 오류가 SQLException 그대로가 아니라 스프링의 DataAccessException(RuntimeException의 하위)으로 바뀌어 던져지므로, DAO의 SQL 실패는 기본 설정만으로도 rollback 됩니다. REQUIRED(기본값)는 이미 진행 중인 트랜잭션이 있으면 합류하고, 없으면 새로 시작합니다.
④ root와 servlet, 두 컨텍스트
web.xml의 리스너가 root-context.xml(+aop-context.xml)로 부모 컨텍스트를, DispatcherServlet이 servlet-context.xml로 자식 컨텍스트를 만듭니다. 자식은 부모의 빈을 쓸 수 있지만 반대는 안 됩니다. tx:annotation-driven은 자기가 선언된 컨텍스트의 빈에만 적용돼요. 그래서 08은 servlet은 controller만, root는 service·dao를 스캔하도록 나눴습니다.
실행 결과B 묶음에서 UPDATE만 실행되고 INSERT가 실패했다면 먼저 예측 → 펼쳐서 확인
refer=2 (seq:step) 전 → 2:0, 6:1 / 후 → 2:0, 6:2
step 1 자리가 비었다 → true
답글은 안 들어갔는데 기존 답글만 한 칸 내려가 빈자리가 생겼습니다. Service 주석의 "결론은 step만 올라가고 답글은 추가되지 않는 상황"이 바로 이것입니다.
XMLroot-context.xml — 트랜잭션 설정과 스캔 범위
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource" /> <!-- ← MyBatis 와 같은 DataSource -->
</bean>
<!-- 선언적 트랜젝션 처리: @transactoinal 사용 -->
<tx:annotation-driven transaction-manager="transactionManager"
proxy-target-class="true" /> <!-- ← 클래스 상속 프록시 -->
<!-- 트랜젝션이 적용되려면 root-context.xml에서 service,dao를 스캔해야됨 -->
<context:component-scan
base-package="com.hk.ansboard.service,com.hk.ansboard.dao" /> <!-- ← servlet 쪽은 controller 만 -->
JAVAAnsService.java — boardReply (수업 주석 일부 생략)
@Transactional(propagation = Propagation.REQUIRED) // ← 이 메서드 전체가 한 작업 단위
public boolean boardReply(AnsDto dto) {
boolean txActive = TransactionSynchronizationManager
.isActualTransactionActive();
logger.info("트랜젝션 활성 상태:{}",txActive); // ← 정말 걸렸는지 눈으로 확인
ansDao.replyUpdate(dto);//step 증가시키는 쿼리
// if(true) {
// throw new RuntimeException("트랜젝션 롤백 테스트"); // ← 주석을 풀면 rollback 실험
// }
int count=ansDao.replyInsert(dto);//답글 추가하는 쿼리
return count>0;
}
실행 결과08 설정 그대로(DB만 가짜 연결) 스프링을 띄워 boardReply 호출 먼저 예측 → 펼쳐서 확인
08의 AnsController·AnsService·AnsDao와 aop-context.xml 원본, root-context.xml의 트랜잭션·스캔 설정을 그대로 썼습니다. DB 대신 호출을 기록하는 가짜 연결과 가짜 SqlSessionTemplate을 넣었으므로, 스프링이 언제 commit·rollback을 부르는지를 확인한 것이고 MariaDB의 실제 되돌리기를 본 것은 아닙니다.
③이 가장 무서운 경우입니다. 자식(servlet)이 service까지 스캔하면 자식 안에 트랜잭션 설정이 없는 AnsService가 하나 더 생기고, Controller는 가까운 자식 쪽 빈을 받습니다. 에러 없이 돌아가지만 트랜잭션이 꺼져 있어, 실패하면 위의 SQLite 결과처럼 UPDATE만 남습니다.
59번 정리 — 핵심 정리
여러 쿼리가 한 업무면 Service 메서드에 @Transactional. 성공하면 commit, RuntimeException이면 rollback.
checked 예외는 기본 commit — 필요하면 rollbackFor. 단 MyBatis-Spring은 SQL 오류를 DataAccessException(Runtime)으로 바꿔 던진다.
TransactionSynchronizationManager.isActualTransactionActive()로 정말 걸렸는지 확인할 수 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
@Transactional을 분명히 붙였는데 로그에 "트랜젝션 활성 상태:false"가 찍힌다. 의심할 곳 두 가지는?
① 컨텍스트 — servlet-context가 service까지 스캔해서, Controller가 트랜잭션 설정이 없는 자식 컨텍스트의 Service를 받고 있지 않은가. ② 호출 경로 — 같은 클래스의 다른 메서드에서 this.boardReply()로 불러 프록시를 거치지 않았는가. 둘 다 "에러 없이 조용히 트랜잭션이 빠지는" 경우라 로그로 확인하는 습관이 중요합니다.
Service에서 throw new Exception("실패")(checked 예외)을 던지면 rollback 될까?
기본 설정에서는 안 됩니다 — commit 됩니다. 스프링의 기본 규칙은 RuntimeException과 Error만 rollback이기 때문입니다. checked 예외에도 되돌리려면 @Transactional(rollbackFor = Exception.class)처럼 적어야 합니다. 반면 DAO의 SQL 실패는 MyBatis-Spring이 DataAccessException(Runtime)으로 바꿔 던지므로 기본 설정으로도 rollback 됩니다.
💼 실무·코딩테스트에서는트랜잭션은 백엔드 면접 최다 출제 주제 중 하나입니다. "@Transactional이 안 먹는 경우"(내부 호출, private 메서드, checked 예외, 다른 컨텍스트)와 전파 속성(REQUIRED·REQUIRES_NEW), ACID가 단골이에요. 실무에서는 트랜잭션을 Service 계층에 거는 것이 표준이고, 스프링 부트에서는 컨텍스트가 하나라 ④의 문제는 드물지만 프록시 원리는 그대로입니다.
60
AOP — DAO를 고치지 않고 모든 DAO 메서드에 로그 남기기
AOPAdvicePointcutJoinPointexecution 표현식
한 줄 요약여러 클래스에 똑같이 들어가는 일(로그·트랜잭션)을 따로 떼어 한 곳(Advice)에 쓰고, "어디에 끼울지"(Pointcut)를 설정으로 정한다. 스프링이 대상 객체를 대리 객체로 감싸 메서드 앞뒤에 그 일을 끼워 넣는다.
쉽게 말하면DAO 메서드마다 "시작합니다·끝났습니다" 일기를 손으로 쓰는 대신, 복도 입구에 자동 출입 기록기를 하나 단 것입니다. DAO 코드에는 로그 한 줄 없는데 로그가 남습니다. 기록 방식을 바꾸고 싶으면 기록기 하나만 고치면 되고요.
① 네 가지 용어 — 08에서 각각 무엇인가
Advice(끼워 넣을 일) = LogExecute의 before·afterReturning·daoError. Pointcut(어느 메서드에 끼울지) = execution(* com.hk.ansboard.dao.*Dao.*(..)). JoinPoint(지금 끼워진 그 실행 지점의 정보) = 메서드 이름·인자·대상 객체. Aspect(Advice + Pointcut 묶음) = <aop:aspect id="logAspect" ref="logAop">.
② 포인트컷 읽기 — execution(반환형 패키지.클래스.메서드(매개변수))
*는 "아무거나 하나", 매개변수의 ..는 "개수·타입 상관없이"입니다. 그래서 execution(* com.hk.ansboard.dao.*Dao.*(..))는 "반환형 상관없이, com.hk.ansboard.dao 패키지에서 이름이 Dao로 끝나는 클래스의, 모든 메서드"입니다.
③ XML 방식과 애너테이션 방식
같은 일을 LogExecuteNoXML은 @Aspect·@Pointcut·@Before·@AfterReturning(returning="result")·@AfterThrowing(throwing="ex")로 씁니다. 이쪽은 반환값과 예외 객체도 받아 로그에 남겨요. 다만 지금은 @Aspect·@Component가 주석이고, 켜려면 <aop:aspectj-autoproxy/>(또는 @EnableAspectJAutoProxy)와 aop 패키지를 포함하는 스캔이 필요합니다. 지금 08의 스캔 범위(controller / service·dao)에는 aop 패키지가 없습니다(소스로 따진 결과). 포인트컷도 XML 쪽은 DAO, 애너테이션 쪽은 service 패키지로 다릅니다.
④ 트랜잭션도 같은 원리
@Transactional 역시 대리 객체가 메서드 앞뒤에 "시작·commit·rollback"을 끼워 넣는 AOP입니다(web-59). aop-context.xml의 주석 부분(tx:advice)은 애너테이션 대신 포인트컷으로 트랜잭션을 거는 방법의 참고용입니다.
XMLaop-context.xml — Advice 등록과 Pointcut 연결
<!-- LogExecute 객체 등록 -->
<bean id="logAop" class="com.hk.ansboard.aop.LogExecute" />
<!-- AOP 환경설정 -->
<aop:config>
<aop:pointcut expression="execution(* com.hk.ansboard.dao.*Dao.*(..))"
id="daoLogPoint"/> <!-- ← 어디에 -->
<!-- advice와 pointcut을 연결하는 설정 -->
<aop:aspect id="logAspect" ref="logAop">
<aop:before method="before" pointcut-ref="daoLogPoint"/> <!-- ← 실행 전 -->
<aop:after-returning method="afterReturning" pointcut-ref="daoLogPoint"/> <!-- ← 정상 종료 후 -->
<aop:after-throwing method="daoError" pointcut-ref="daoLogPoint"/> <!-- ← 예외 시 -->
</aop:aspect>
</aop:config>
JAVALogExecute.java — before · afterReturning (Advice 구현)
// target 메서드가 실행되기 전에 수행될 기능을 정의
public void before(JoinPoint join) {
Logger logger=
LoggerFactory.getLogger(join.getTarget().getClass()+""); // ← "class " 가 붙는 이름 (아래 설명)
logger.info("before실행(info):시작:{}",
join.getSignature().getName()); // ← 메서드 이름
logger.debug("before실행(debug):시작:{}",join.toLongString()); // ← 반환형·매개변수까지 전부
}
//target 메서드가 실행된 후 반환값을 성공적으로 리턴했다면 수행될 기능을 정의
public void afterReturning(JoinPoint join) {
...
Object[] args = join.getArgs(); // ← 넘어온 인자들
logger.debug("전달 파라미터:{}",Arrays.toString(args));
}
실행 결과답글 저장 한 번에 찍힌 AOP 로그 (web-59와 같은 실행) 먼저 예측 → 펼쳐서 확인
INFO class com.hk.ansboard.dao.AnsDao - before실행(info):시작:replyUpdate
DEBUG class com.hk.ansboard.dao.AnsDao - before실행(debug):시작:execution(public int com.hk.ansboard.dao.AnsDao.replyUpdate(com.hk.ansboard.dtos.AnsDto))
INFO class com.hk.ansboard.dao.AnsDao - afterReturning실행(info):시작:replyUpdate
DEBUG class com.hk.ansboard.dao.AnsDao - 전달 파라미터:[AnsDto [seq=1, id=kim, title=re, …]]
INFO class com.hk.ansboard.dao.AnsDao - before실행(info):시작:replyInsert
…
(replyInsert가 실패한 경우)
INFO class com.hk.ansboard.dao.AnsDao - daoError실행(info):시작:replyInsert
로그 이름이 class com.hk.ansboard.dao.AnsDao로 "class " 글자가 앞에 붙었습니다.getClass()+""는 Class 객체를 문자열로 바꾼 것이라 "class 패키지.이름"이 되기 때문이에요. 로그 설정은 보통 com.hk.ansboard처럼 패키지 이름으로 수준을 정하므로, 이 이름은 그런 설정에 걸리지 않습니다. 주석 처리된 LogExecuteNoXML처럼 LoggerFactory.getLogger(join.getTarget().getClass())로 Class를 그대로 넘기면 됩니다.
출력 모양은 검증용 Logback 설정으로 스프링 내부 로그만 줄인 것입니다.
60번 정리 — 핵심 정리
AOP = 공통 기능(Advice)을 떼어 두고, 끼울 곳(Pointcut)을 설정으로 정한다.
before / after-returning / after-throwing — 실행 전, 정상 종료 후, 예외 시.
execution(* 패키지.*Dao.*(..)) 형태를 읽을 수 있어야 한다 — * 하나, .. 아무 매개변수.
로거 이름은 getLogger(클래스)로 — getClass()+""는 "class "가 붙는다.
@Transactional도 프록시 기반 AOP다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
AnsService의 메서드 실행에는 이 로그가 찍힐까? 이유는?
안 찍힙니다. XML 포인트컷이 com.hk.ansboard.dao 패키지의 *Dao 클래스로 한정돼 있어서, service 패키지의 AnsService는 대상이 아닙니다. 서비스에도 걸고 싶다면 포인트컷을 하나 더 만들거나 표현식을 넓혀야 해요(애너테이션 방식 LogExecuteNoXML이 service를 노리지만 지금은 꺼져 있습니다).
root는 DEBUG로 두고 com.hk.ansboard만 INFO로 올렸는데 AOP의 DEBUG 로그가 계속 나온다. 왜일까?
로거 이름이 "class com.hk.ansboard.dao.AnsDao"라서 com.hk.ansboard로 시작하지 않기 때문입니다. 패키지 단위 설정에 걸리지 않고 root 수준(DEBUG)을 따르게 되죠. getLogger(join.getTarget().getClass())로 Class를 그대로 넘기면 이름이 com.hk.ansboard.dao.AnsDao가 되어 설정대로 동작합니다.
💼 실무·코딩테스트에서는실무에서 AOP는 로그·실행 시간 측정·트랜잭션·권한 검사·예외 변환처럼 "모든 곳에 필요하지만 본업은 아닌" 일에 씁니다. 대부분 XML보다 @Aspect 애너테이션 방식이고, @Around로 실행 전후를 한 번에 감싸는 경우가 많아요. 면접 단골은 "AOP 용어(Advice·Pointcut·JoinPoint·Aspect)"와 "스프링 AOP는 프록시 기반이라 내부 호출에는 적용되지 않는다"입니다.
한 줄 요약필드에 @Autowired를 붙이는 대신 생성자로 의존 객체를 받는다. 생성자가 하나뿐이면 @Autowired를 적지 않아도 스프링이 그 생성자로 넣어 준다. 목록·페이지 수·페이지 번호를 모으는 일은 Service가 하고, Controller는 결과를 Model에 담기만 한다.
쉽게 말하면입사할 때 필요한 장비를 첫날 손에 쥐여 주는 것(생성자)과, 일하다 누가 책상에 슬쩍 놓고 가는 것(필드 주입)의 차이입니다. 앞의 방식이면 장비 없이는 아예 입사가 안 됩니다. 그리고 접수 창구 직원(Controller)은 서류를 받아 전달만 하고, 서류를 모으고 계산하는 일은 담당 부서(Service)가 하는 게 깔끔하죠.
① 생성자 주입 — 생성자가 하나면 @Autowired 생략
42일차에 AnsController의 필드 @Autowired를 주석 처리하고 생성자 AnsController(AnsService ansService)를 만들었습니다. 스프링은 생성자가 하나뿐이면 그 생성자를 써서 필요한 빈을 넣어 줍니다(Spring 4.3부터). 실제로 web-59의 재현에서 Controller는 이 생성자로 프록시 Service를 받았습니다. 참고로 AnsService 안의 AnsDao는 아직 필드 @Autowired 그대로입니다.
② 생성자 주입이 권장되는 이유
필요한 객체가 없으면 객체 자체가 만들어지지 않아 문제를 서버 시작 시점에 일찍 알 수 있습니다. 필드에 final을 붙여 바뀌지 않게 할 수 있고(08은 아직 안 붙였다), 테스트에서 new AnsController(가짜서비스)처럼 스프링 없이 만들 수 있습니다.
③ 얇은 Controller — 조립은 Service에서
"목록 + 페이지 수 + 번호 계산"은 화면 이동이 아니라 업무 규칙입니다. Controller는 요청을 받고 화면을 고르는 일만 하면 읽기 쉽고, 같은 조립을 다른 곳에서도 다시 쓸 수 있어요. Service가 돌려준 Map은 model.addAllAttributes(map)로 한 번에 담는데, 이는 Map의 키마다 addAttribute를 한 것과 같아서 JSP에서 ${list}·${pCount}·${pMap.startPage}로 꺼냅니다.
JAVAAnsController.java — 생성자 주입과 boardList
// @Autowired
private AnsService ansService; // ← final 을 붙일 수 있는 자리
public AnsController(AnsService ansService) { // ← 생성자가 하나 → @Autowired 생략 가능
this.ansService=ansService;
}
@RequestMapping(value = "/boardList.do", method = RequestMethod.GET)
public String boardList(Model model,
@RequestParam(value="pnum", defaultValue = "1") String pnum) {
// int pCount=ansService.getPcount();
// List<AnsDto>list=ansService.getAllList(pnum);
// model.addAttribute("list", list);
// model.addAttribute("pCount", pCount);
//위 코드 작업을 AnsService에서 처리하자
Map<String, Object>result=ansService.getBoardListWithPaging(pnum); // ← 조립은 Service 가
model.addAllAttributes(result); // ← 키마다 addAttribute
model.addAttribute("pnum", pnum);//현재 페이지상태를 유지하기 위해 전달
return "boardList";
}
Map의 키 이름이 곧 JSP의 EL 이름이 된다 — 키를 바꾸면 JSP도 함께 바꿔야 한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
AnsController에 매개변수 없는 생성자를 하나 더 추가하면 어떻게 될까?
생성자가 둘이 되고 어느 쪽에도 @Autowired가 없으면, 스프링은 매개변수 없는 기본 생성자를 씁니다. 그러면 ansService가 null인 채로 Controller가 만들어져, 목록을 여는 순간 NullPointerException이 납니다. 생성자가 여럿이면 주입에 쓸 생성자에 @Autowired를 붙여 알려 줘야 합니다.
Service에서 resultMap.put("pMap", …)의 키를 "paging"으로 바꾸면 화면은?
에러 없이 페이지 번호 부분만 엉뚱해집니다. JSP가 찾는 pMap이 없으니 EL은 오류 대신 빈 값으로 처리해서, Previous·Next 링크가 boardList.do?pnum=처럼 비고 번호 반복도 제대로 계산되지 않습니다. 화면이 깨졌는데 오류 메시지는 없는 상황이라 찾기가 더 어렵죠. addAllAttributes는 편하지만 키 이름이 Service와 JSP 사이의 약속이 된다는 점을 기억해야 합니다.
💼 실무·코딩테스트에서는스프링 공식 문서와 대부분의 팀이 생성자 주입을 기본으로 씁니다. 실무에서는 private final 필드 + Lombok의 @RequiredArgsConstructor로 생성자를 자동으로 만드는 모습을 가장 많이 보게 될 거예요. 면접에서는 "필드 주입 대신 생성자 주입을 쓰는 이유"와 "Controller·Service·DAO 각 계층의 책임"이 자주 나옵니다 — "Controller에 업무 로직이 쌓이면 왜 나쁜가"를 이번 리팩터링으로 설명할 수 있습니다.
🧭 학습 여정 — 개념이 쌓이는 순서
자바 교안부터 SQL·웹 개발까지 지금까지 배운 내용을 "왜 이 순서로 배우는가"라는 하나의 흐름으로 엮은 지도입니다. 📅 수업 진도 탭이 날짜별 기록이고 과목 탭이 항목별 사전이라면, 이 탭은 앞에서 배운 것이 뒤에서 어떻게 다시 쓰이는지를 이어서 보여 줍니다.
💡 이 탭을 보는 법
단계마다 이전 단계에서 배운 것을 명시적으로 언급하며 이어집니다 — "값을 담는 법"에서 시작해 "흐름을 제어하는 법", "코드에 이름을 붙이는 법", "설계도와 실체", "물려받고 바꿔 쓰는 법" 순으로 넓어져요. 상자는 개념 하나, 화살표는 "그걸 가지고 다음에 무엇을 하는지"입니다. 각 단계의 전체 카드 보기를 누르면 해당 과목 탭의 자세한 설명으로 바로 이동합니다. 현재는 스프링 게시판에 트랜잭션과 AOP를 붙이는 단계입니다. 10월 2일 진도와 24단계에서 이어서 복습하세요.
문법보다 먼저 배우는 것이 "내가 쓴 글자가 어떻게 프로그램이 되는가"입니다. .java를 javac가 바이트코드로 번역하고, 그걸 각 OS의 JVM이 해석해 실행합니다. 이 2단계 구조를 알아야 "파일명은 왜 클래스명과 같아야 하나", "package는 왜 폴더와 일치해야 하나"가 규칙이 아니라 이유로 이해됩니다. 이름 짓는 규칙(명명법)도 여기서 함께 잡습니다.
.java사람이 쓴 소스
javac
.class바이트코드(중간 언어)
java
JVMOS마다 다른 실행기
여기서 잡는 감각
JDK ⊃ JRE ⊃ JVM — 개발은 JDK, 실행만 하면 JRE.
클래스=파스칼, 메서드·변수=카멜, 상수=대문자 — 이후 모든 코드가 이 규칙을 따릅니다.
01에서 "코드가 돌아가는 구조"를 봤다면 여기서는 그 안에 값을 담는 법을 배웁니다. 기본타입 8종은 결국 상자의 크기표이고, 크기가 다르니 큰 상자의 값을 작은 상자에 옮길 때 문제가 생긴다는 것이 형 변환의 전부입니다. 2의 보수는 "왜 byte가 -128부터인가"에 대한 답이라, 표현 범위를 외우지 않고 이해하게 해 줍니다.
변수 · 스코프어디까지 살아 있나
담을 크기
기본타입 8종byte ~ boolean
크기가 다르니
형 변환자동 승격 · 강제 캐스팅
여기서 잡는 감각
정수 리터럴 기본 int, 실수 리터럴 기본 double — L과 f 접미사가 필요한 이유.
블록 { }을 벗어나면 지역변수가 사라진다 — 03단계 반복문에서 곧바로 부딪히는 규칙입니다.
02까지는 값을 담기만 했습니다. 여기서 연산자로 값을 굴리고, 그 결과로 길을 고르고(조건문), 같은 일을 반복합니다(반복문). 갈림길이 둘이면 if~else, 여럿이면 switch, 횟수를 알면 for, 모르면 while — 이 네 가지 조합이 이후 모든 실습 코드의 뼈대입니다. 중첩 for는 구구단·별 찍기로 손에 붙이고, 줄마다 개수를 i의 식으로 세우는 감각까지 여기서 만들어집니다.
연산자산술 · 관계 · 논리 · 삼항
결과가 true/false
조건문if~else · switch
-
횟수만큼
반복문for · while · do~while
둘을 겹치면
중첩 for구구단 · 별 찍기
여기서 잡는 감각
&&가 ||보다 우선순위가 높다 — 윤년 조건식이 이걸 그대로 쓰는 예입니다.
별 찍기는 도형이 아니라 개수 식 세우기 — 피라미드는 공백 n-1-i개 + 별 2i+1개.
03까지 만든 계산 결과를 사람이 볼 수 있게 내보내고(출력), 사람이 넣은 값을 받아들입니다(입력). printf의 서식 문자는 이후 표 모양 출력(구구단·마방진·달력)에서 계속 쓰이고, Scanner는 실습과제 대부분의 시작점이 됩니다. 여기서 만나는 nextInt() 뒤에 nextLine()이 건너뛰어지는 함정은 초보자가 반드시 한 번은 겪는 문제입니다.
print · println줄바꿈 유무
서식을 주면
printf%d %s %.2f · \t
Scanner키보드 입력
숫자로 바꿔
Integer.parseInt개행 함정을 피하는 방식
여기서 잡는 감각
print는 줄바꿈 없음 — 실행 결과에서 프롬프트와 입력이 붙어 보이는 이유입니다.
입력은 Integer.parseInt(sc.nextLine())로 통일하면 함정이 아예 없어집니다. → 📖 Scanner 함정 카드
01~04로 돌아가는 코드는 만들 수 있게 됐지만, 전부 main 안에 있습니다. 여기서 자주 쓰는 코드 덩어리에 이름을 붙여 밖으로 꺼냅니다. 매개변수는 "받을 것", 반환타입은 "돌려줄 것"이고, 이 둘의 유무로 메서드는 네 종류가 됩니다. 그리고 main이 static이라 생기는 벽을 여기서 처음 만나는데, 이 벽이 다음 단계(객체)의 필요성을 그대로 설명해 줍니다.
main 안의 코드전부 한 곳에
이름을 붙여
메서드로 분리매개변수 · 반환타입
호출하려면
static? 객체?다음 단계로 이어짐
여기서 잡는 감각
반환타입을 선언했으면 모든 경로에서 return, 실행만 할 거면 void.
static은 non-static을 직접 못 쓴다 — 06단계에서 "객체를 만든다"는 답을 얻습니다. → 📖 해당 에러 카드
4일차 D1_divisor가 이 단계의 완성형 — 약수·최대공약수·친화수를 메서드로 쪼개고 서로 재사용했습니다.
05에서 코드에 이름을 붙였다면, 여기서는 데이터와 그 데이터를 다루는 메서드를 한 덩어리로 묶습니다 — 그게 클래스입니다. 클래스는 설계도일 뿐이고 new를 해야 Heap에 실체(객체)가 생깁니다. 그 순간 딱 한 번 실행돼 초기값을 정하는 것이 생성자이고, 주문 방식을 여러 개 두는 것이 생성자 오버로딩입니다.
클래스설계도(코드)
new
객체(Heap)실제로 만들어진 것
태어날 때
생성자초기값 설정 · 오버로딩
여기서 잡는 감각
매개변수 있는 생성자를 만들면 기본 생성자는 자동으로 안 생긴다.
생성자 첫 줄에는 this(...)나 super(...) 중 하나만.
오버로딩은 매개변수의 개수·타입·순서로 구분 — 반환타입만 다른 건 안 됩니다. → 📖 오버로딩 vs 오버라이딩
06에서 객체를 만들 수 있게 되자 곧바로 두 가지 질문이 생깁니다 — "아무나 필드를 고쳐도 되나?"와 "객체마다 따로여야 하나, 다 같이 써야 하나?". 앞의 답이 private + getter/setter(캡슐화)이고, 뒤의 답이 static입니다. 여기까지 오면 02단계에서 배운 "메모리 어디에 저장되나"가 Method Area / Stack / Heap이라는 그림으로 완성됩니다.
private 필드직접 접근 차단
창구를 만들어
getter · setter조건을 끼워 넣을 수 있다
인스턴스 멤버객체마다 따로(Heap)
vs
static 멤버클래스에 하나(Method Area)
여기서 잡는 감각
접근 범위 넓은 순서 — public > protected > (default) > private.
06에서 new를 배운 순간부터 사실 변수가 값이 아니라 주소를 들고 있는 세계에 들어와 있었습니다. 여기서 그 사실을 정면으로 다룹니다 — 대입하면 주소만 복사되고(얕은 복사), ==는 주소를 비교하며, 그래서 내용 비교는 equals()가 필요합니다. 모든 클래스의 부모인 Object가 equals·hashCode·toString을 물려주기 때문에 이 이야기가 성립하고, String은 그 중 equals를 내용 비교로 재정의해 둔 특별한 예입니다.
06~07에서 클래스를 한 개씩 잘 만들 수 있게 됐다면, 여기서는 클래스끼리 관계를 맺습니다. 공통된 것을 부모에 두고 물려받아(상속), 다르게 동작해야 하는 부분만 자식이 다시 정의하고(오버라이딩), 그 덕분에 부모 타입 하나로 여러 자식을 똑같이 다룰 수 있게 됩니다(다형성). 08단계에서 배운 Object가 사실 이미 모두의 부모였다는 것도 여기서 제자리를 찾습니다.
부모 클래스공통 필드 · 메서드
extends
자식 클래스물려받아 확장
같은 모양으로 재정의
오버라이딩@Override
-
그 결과
다형성부모 타입으로 자식들을 한 번에
여기서 잡는 감각
자바는 클래스 다중 상속을 지원하지 않는다 — 대신 인터페이스가 그 자리를 메웁니다(10단계).
09의 상속은 "완성된 것을 물려주는" 방식이었습니다. 여기서는 반대로 "이름만 정해 두고 내용은 자식에게 맡기는" 방식을 배웁니다 — 그게 추상 클래스와 인터페이스입니다. 자바가 클래스 다중 상속을 막은 대신 인터페이스는 여러 개 구현할 수 있게 열어 두었죠. 제네릭은 여기에 "어떤 타입을 담을지 나중에 정한다"를 더해, 다음 단계 컬렉션의 ArrayList<String> 문법을 미리 준비시킵니다.
추상 클래스완성 + 미완성 섞어서(하나만)
또는
인터페이스약속 목록(여러 개 가능)
타입까지 미루면
제네릭 <T>담을 타입을 나중에
여기서 잡는 감각
둘 다 new로 직접 객체를 만들 수 없다.
공통 코드를 물려주려면 추상 클래스, 상관없는 클래스들에 같은 기능을 강제하려면 인터페이스.
08의 배열은 크기를 미리 정해야 했습니다. 실전에서는 몇 개가 들어올지 모르는 경우가 훨씬 많아서, 크기가 자동으로 늘어나는 컬렉션이 필요해집니다 — List(순서·중복 O), Set(중복 X), Map(키-값). 그리고 프로그램이 커질수록 "잘못될 수 있는 지점"이 늘어나므로, 터뜨리는 대신 붙잡아 처리하는 try~catch를 배웁니다.
11까지가 "한 프로그램 안에서 데이터를 다루는 법"이었다면, 여기서는 그 데이터를 더 짧게 표현하고(람다·Stream), 프로그램 밖으로 내보내고 들여오고(IO), 여러 흐름을 동시에 굴립니다(스레드). 람다는 10단계의 인터페이스(메서드 하나짜리)를 짧게 쓴 형태라 앞 단계를 알아야 이해되는 문법이고, 스레드는 다음 단계인 네트워크 서버에서 곧바로 필요해집니다.
람다식인터페이스 하나를 짧게
이어 붙이면
Stream중간 연산 → 최종 연산
IO 스트림파일 · 콘솔 읽고 쓰기
동시에 여러 개
스레드생성 · 동기화 · 생명주기
여기서 잡는 감각
Stream의 중간 연산은 최종 연산이 호출돼야 실제로 돈다(지연 연산).
IO는 다 쓰면 close() — try-with-resources가 이걸 자동으로 해 줍니다.
공유 자원을 여럿이 건드리면 synchronized가 필요 — 13단계 채팅 서버의 전제입니다.
마지막 단계는 지금까지 만든 프로그램을 다른 컴퓨터와 연결하는 일입니다. IP는 주소, 포트는 그 안의 방 번호이고, 소켓은 두 프로그램 사이에 놓인 통로입니다. 통로로 흐르는 데이터를 읽고 쓰는 방식은 12단계의 IO 스트림 그대로이고, 여러 명이 동시에 접속하는 채팅 서버는 12단계의 스레드가 있어야 만들 수 있습니다 — 즉 이 단계는 앞의 모든 것이 합쳐지는 자리입니다.
IP · 포트주소 + 방 번호
연결 통로
Socket · ServerSocketTCP(신뢰) / UDP(속도)
데이터는
IO 스트림12단계 그대로
-
여러 명이면
멀티스레드 서버접속마다 스레드 하나
🏁 지금 수업이 여기입니다 — 24단계 중 웹 개발 7단계(18~24)까지 — 2026-08-28(19일차)로 자바 교안의 마지막 단계까지 도달했고, 2026-08-31(20일차)부터는 HeidiSQL로 MySQL을 다루는 SQL 과정이 시작됐습니다(→ 🗄️ SQL 기초 탭). 18일차에 Socket·ServerSocket으로 기본 TCP 대화를, 19일차에 접속마다 스레드를 만드는 멀티스레드 서버, UDP 에코, 그리고 소켓+스레드+컬렉션+예외 처리가 전부 합쳐진 채팅 서버까지 완성했습니다. 시험 대비 탭의 📅 배운 데까지 범위도 자바 전 범위를 출제합니다 — 자바 복습은 🧪 실습과제 탭의 12문제로 마무리하고, 2026-09-08(26일차) 기준으로 SQL도 제약조건·뷰·인덱스까지 나갔습니다 — 🗄️ SQL 기초 탭의 46개 카드가 그 전 범위이고, 시험 대비 탭에도 SQL 30문항이 들어 있습니다. 이어서 2026-09-10(28일차)부터 JSP·Servlet 웹 개발 과정이 시작됐고, 2026-09-21(35일차)에 19단계 — EL·JSTL을 마쳤습니다. 2026-09-22(36일차)부터는 20단계 — 스프링 MVC로, 지금까지 직접 만들던 구조를 프레임워크에 넘기기 시작했습니다. 2026-09-23(37일차)에는 21단계 — Spring과 MyBatis DB 연동으로 목록 요청을 실제 데이터까지 연결했습니다. 2026-09-28(38일차)에는 22단계 — CRUD 요청 연결로 글쓰기·상세·수정·삭제를 Controller 메서드에 붙였습니다. 2026-09-29~30(39~40일차)에는 23단계 — 답변형 게시판을 시작해 refer·step·depth 구조와 논리 삭제, 로그를 다뤘습니다. 2026-10-01~02(41~42일차)에는 24단계 — 트랜잭션과 AOP로 페이지 번호·답글을 완성하고 여러 쿼리를 한 작업으로 묶었습니다.
여기서 잡는 감각
TCP는 연결을 맺고 보내 신뢰성이 높고, UDP는 맺지 않고 던져 빠릅니다.
서버는 accept()에서 접속이 올 때까지 멈춰 기다립니다.
여기까지 오면 자바 교안 전 범위가 한 바퀴 — 실습과제 12문제로 종합 점검하세요. → 🧪 실습과제 탭
19일차 D3_ChatServer가 이 단계의 완성형 — 접속마다 스레드, 공유 명단은 synchronizedSet, 방송은 broadcast()로 정리했습니다.
자바가 "어떻게 할지"를 한 줄씩 지시하는 언어였다면, SQL은 "무엇을 원하는지만 말하는" 언어입니다. "월급 2000 이하인 사람 뽑아줘"라고 쓰면 어떻게 찾을지는 데이터베이스가 알아서 합니다. 반복문도 조건문도 직접 쓰지 않죠. 이 발상의 전환이 SQL의 첫 관문입니다.
여기서 잡는 감각
SELECT는 세로(열)를, WHERE는 가로(행)를 고른다.
실행 순서는 FROM → WHERE → SELECT — 그래서 별칭을 WHERE에서 못 쓴다.
한 줄씩 보던 데이터를 덩어리로 묶어 숫자 하나로 요약하는 단계입니다. 자바에서 반복문을 돌려 합계를 구하던 것을 SQL은 SUM() 한 단어로 끝냅니다. 여기서 NULL이 다시 발목을 잡습니다 — 집계 함수는 NULL을 아예 빼고 계산하기 때문에, 분모가 생각과 달라집니다.
조회에서 설계로 넘어가는 마지막 단계입니다. 제약조건은 데이터베이스가 대신 지켜 주는 규칙이라, 프로그램이 여러 개여도 규칙은 한 곳에서만 관리됩니다. 뷰는 복잡한 조회를 감추고, 인덱스는 검색을 빠르게 합니다 — 다만 공짜는 없어서 인덱스가 많으면 입력·수정이 느려집니다.
여기서 잡는 감각
제약조건은 나중에도ALTER TABLE ... ADD CONSTRAINT로 붙일 수 있다.
여기서 지금까지 배운 두 가지가 만납니다. 자바(로직)와 SQL(데이터)을 따로 배웠는데, 그 사이를 잇는 규격이 JDBC입니다. 그리고 결과를 브라우저에 보여 줄 무대가 Tomcat(WAS)이에요. 여기서부터는 내가 실행 버튼을 누르는 프로그램이 아니라, 요청이 오면 WAS가 대신 실행해 주는 프로그램을 만듭니다.
여기서 잡는 감각
웹 애플리케이션에는 main()이 없다 — 실행의 주도권이 WAS에 있다.
JDBC는 정해진 6단계다: 로딩 → 연결 → 준비 → 실행 → 결과 → 닫기.
값은 ?로 바인딩한다 — 문자열로 이어 붙이면 SQL 인젝션에 뚫린다.
DTO는 데이터를 담고, DAO는 DB에 접근한다 — 바뀔 이유가 다르면 클래스를 나눈다.
앞 단계에서 JSP 한 장이 요청을 받고 · DB를 부르고 · 화면까지 그렸습니다. 여기서는 그 덩어리를 두 번에 걸쳐 쪼갭니다. 먼저 MVC2가 "무엇을 할지 정하는 일"을 서블릿(Controller)으로 빼내고, 그다음 EL과 JSTL이 "화면에 뿌리는 일"에서 자바 문법을 걷어냅니다. 같은 분리를 서로 다른 축에서 한 번씩 하는 셈이에요.
여기서 잡는 감각
Controller는 URL로 요청을 구분한다 — *.board 매핑 + getRequestURI(). 파라미터 command로 나누던 방식을 대체한다.
화면으로 객체를 넘길 땐 Scope에 담고 forward, 저장 뒤에는 redirect — 새로고침으로 같은 등록이 반복되지 않게 한다.
JSP에 자바를 쓰지 않아도 된다 — ${ }는 Scope에서 찾아 getter를 부른다.
반복과 분기는 태그로 쓴다 — <c:forEach> · <c:choose>. 여닫는 짝이 HTML과 같은 모양이라 구조가 보인다.
04와 05를 가르는 것은 기능이 아니라 화면을 그리는 방법 하나다 — 자바 코드는 그대로다.
앞 두 단계에서 요청 분기는 서블릿으로, 화면 로직은 태그로 각각 빼냈습니다. 그런데 그 서블릿은 여전히 내가 직접 짠 것이었어요 — URL을 읽고, 조건문으로 갈라내고, forward 경로를 적는 일이 매 프로젝트마다 반복됐습니다. 스프링은 그 반복되는 부분을 통째로 가져갑니다. 내가 남기는 것은 "이 URL은 이 메서드"라는 표시와 화면 이름뿐입니다.
JSP 한 장받고 · 조회하고 · 그린다
MVC2
내 Controller분기를 직접 짠다
Spring
@RequestMapping표시만 하면 된다
여기서 잡는 감각
제어의 주도권이 한 겹 더 넘어간다 — 28일차에 "WAS가 내 코드를 실행한다"를 배웠고, 여기서는 객체를 만드는 일까지 컨테이너가 가져갑니다(IoC).
라이브러리는 가져다 넣는 것이 아니라 선언하는 것이 됩니다 — pom.xml에 적으면 의존성까지 따라옵니다.
화면은 경로가 아니라 이름으로 부릅니다 — prefix·suffix를 ViewResolver가 붙입니다.
직접 만들어 본 04·05가 있어야 "무엇이 사라졌는지"가 보입니다. 프레임워크부터 배우면 이 비교 대상이 없습니다.
21단계에서 목록 하나를 DB까지 연결했다. 이번에는 글쓰기·상세·수정·삭제를 같은 방식으로 붙인다. 요청 분기는 @RequestMapping이, 폼 값을 객체로 옮기는 일은 스프링의 바인딩이 맡는다. 남은 선택은 "저장한 뒤 어디로 보낼까"이고, 그 답이 redirect다.
URL 하나에 메서드 하나 — MVC2의 if문 분기가 사라진다.
폼 값은 DTO로 한 번에, 같은 이름 여러 개는 배열로 받는다.
데이터를 바꾼 뒤에는 redirect(PRG) — 새로고침 중복 저장을 막는다.
Spring 6.1에서는 이름으로 받는 매개변수에 @RequestParam("이름")을 적는다.
22단계까지 만든 CRUD 흐름은 그대로 두고, 데이터 쪽을 바꾼다. 답글은 따로 테이블을 두지 않고 같은 테이블에 넣되, refer·step·depth 세 숫자로 "어느 묶음의 몇 번째 줄, 몇 칸 들여쓰기"를 표시한다. 글을 지울 때도 행을 없애지 않고 표시만 바꾼다. 그래야 답글 구조가 무너지지 않는다.
목록은 refer 내림차순 · step 오름차순 — 새 묶음이 위, 묶음 안에서는 원글 다음에 답글.
페이지 나누기는 ROW_NUMBER로 번호를 붙인 뒤 번호로 거른다.
논리 삭제(delflag)와 조회수 redirect — 상태를 바꾼 뒤엔 새로고침을 안전하게.
23단계에서 세운 refer·step·depth 구조로 실제로 답글을 달자, 쿼리 두 개(step 밀기·답글 넣기)가 함께 성공해야 하는 문제가 생겼다. 스프링은 이 문제와 "모든 DAO에 로그 남기기"를 같은 방법으로 푼다 — 객체를 대리 객체(프록시)로 감싸고, 메서드 앞뒤에 필요한 일을 끼워 넣는다.
수업 진도와 상관없이 궁금한 용어 하나만 찾아보는 사전입니다. 앞의 과목 탭이 "교안을 순서대로 정리한 노트"라면, 여기는 "그 용어가 정확히 무슨 뜻이냐"에만 답합니다. 특히 그룹 D는 빨간 에러 메시지를 그대로 찾아볼 수 있는 사전이에요 — 코드가 안 돌아갈 때 메시지를 여기서 검색해 보세요.
🧭 처음이라면 이 순서대로 읽어보세요
1
"자바가 내 코드를 어떻게 돌리는가"부터
변수 하나가 어디에 저장되는지를 알면, 뒤에 나오는 static·참조·NullPointerException이 전부 같은 그림 위에서 설명됩니다. 자바에서 가장 먼저 잡아야 할 감각입니다.
한 줄 요약사람이 쓴 .java를 javac가 바이트코드(.class)로 번역하고, 그 바이트코드를 각 운영체제용 JVM이 다시 그 컴퓨터가 알아듣는 말로 바꿔 실행한다 — 이 2단계 번역이 "한 번 짜면 어디서나 돌아간다"의 정체다.
쉽게 말하면한국어 소설을 세계에 파는 상황이에요. 나라마다 번역가를 두는 대신 먼저 에스페란토(바이트코드) 한 벌로 번역해 두고, 각 나라에 에스페란토를 읽어 주는 통역사(JVM)를 한 명씩 심어 둡니다. 그러면 원고는 하나만 쓰면 되죠. 그 통역사가 윈도우용 JVM, 맥용 JVM입니다.
세 글자(JDK · JRE · JVM)의 포함 관계
JDK ⊃ JRE ⊃ JVM 순서로 감싸고 있습니다. 가장 안쪽 JVM이 바이트코드를 실제로 실행하는 엔진, 그걸 감싼 JRE는 JVM + 실행에 필요한 기본 라이브러리(String, Scanner 같은 것), 가장 바깥 JDK는 JRE + javac·javap·javadoc 같은 개발 도구입니다. 그래서 코드를 짜려면 JDK, 남이 만든 프로그램을 돌리기만 하면 JRE로 충분합니다. → ☕ 01. JDK · JRE · JVM과 컴파일 과정
"파일명 = public 클래스명"이 강제인 이유
javac는 클래스 하나당 .class 파일 하나를 만듭니다. .class 파일의 이름은 소스 파일명이 아니라 클래스명에서 나오므로, java HelloJava라고 치면 JVM은 HelloJava.class를 찾아 그 안의 main을 부릅니다. "public 클래스명 = 파일명" 규칙은 컴파일러(javac)가 소스 파일을 찾기 위한 약속입니다 — 다른 클래스가 HelloJava를 쓰면 javac는 HelloJava.java라는 파일을 찾아 함께 컴파일하므로, 이름이 다르면 컴파일 단계에서 막습니다. package 선언이 폴더 구조와 일치해야 하는 것도 같은 이유 — 이름이 곧 찾아가는 경로이기 때문입니다.
파일명 = public 클래스명, package 선언 = 폴더 경로. 둘 다 "이름으로 찾아가기 때문"에 강제된다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
바이트코드라는 중간 단계가 없다면 자바는 어떻게 배포해야 할까?
OS·CPU 조합마다 따로 컴파일해 배포해야 합니다. C/C++이 그렇죠 — 윈도우용, 리눅스용, 맥용 실행 파일을 각각 만듭니다. 자바는 중간 단계 하나를 끼워 넣어 그 부담을 JVM 제작자에게 넘긴 것입니다.
public 클래스명과 파일명이 같아야 하는 이유는?
JVM이 이름으로 파일을 찾기 때문입니다. Main 클래스를 실행하라고 하면 Main.class를 찾죠. 이름이 다르면 찾을 수가 없습니다. package 선언과 폴더 구조가 일치해야 하는 것도 같은 이유입니다.
💼 실무·코딩테스트에서는실무에서 빌드 도구(Gradle·Maven)가 이 과정을 자동화하지만, ClassNotFoundException이나 NoClassDefFoundError를 만나면 결국 "이름으로 찾는다"는 원리로 되돌아가 클래스패스를 확인하게 됩니다.
02
메모리 3영역 — Method Area · Stack · Heap
static 영역스택힙지역변수
한 줄 요약자바가 값을 두는 자리는 셋뿐이다 — Method Area(static·클래스 정보, 프로그램 내내 하나), Stack(메서드 안 지역변수·매개변수, 메서드가 끝나면 사라짐), Heap(new로 만든 객체, 아무도 안 쓰면 나중에 치워짐).
쉽게 말하면사무실로 비유하면 Method Area는 벽에 붙은 공지사항(하나뿐이라 모두가 같은 걸 본다 — static 변수 값은 바뀔 수 있지만, 바뀌면 모두에게 바뀐 값이 보인다), Stack은 책상 위 메모지(일 하나 하는 동안만 쓰고 끝나면 버림), Heap은 창고(물건을 계속 쌓아 두고 필요할 때 번호표로 꺼냄)입니다. 변수 하나를 볼 때 "이건 공지사항이야, 메모지야, 창고야?"만 물어도 대부분의 헷갈림이 풀립니다.
어느 변수가 어디로 가는가
Method Area(static 영역) — static이 붙은 변수·메서드, 그리고 클래스 정보 자체. 그 클래스가 처음 사용될 때(클래스 로딩) 한 번 올라가서, 객체를 만들지 않아도 존재합니다. 그래서 클래스명.메서드()로 바로 부를 수 있죠.
Stack — 메서드 안에서 선언한 지역변수와 매개변수. 메서드가 호출될 때 그 메서드 몫의 칸(스택 프레임)이 쌓이고 메서드가 끝나는 순간 통째로 사라집니다. 참고로 블록 스코프({ } 밖에서 못 쓴다)는 이것과 별개인 컴파일 시점의 규칙입니다 — 프레임은 메서드 단위로 생기고, 블록 밖에서 변수 이름을 못 쓰게 막는 것은 컴파일러입니다.
Heap — new로 만든 모든 객체(배열도 객체입니다). 객체 안의 인스턴스 변수는 객체와 함께 Heap에 있습니다.
D2_ClassTest c = new D2_ClassTest(); 한 줄에서 두 곳이 동시에 쓰입니다. 오른쪽 new ...가 Heap에 객체를 만들고, 왼쪽 c는 Stack에 놓인 변수인데 그 안에 든 것은 객체 자체가 아니라 객체가 있는 창고 번호(주소)입니다. 이 그림이 잡히면 참조·== vs equals·NullPointerException이 전부 같은 이야기로 보입니다.
핵심 정리
static · 클래스 정보 → Method Area, 지역변수 · 매개변수 → Stack, new로 만든 객체 → Heap.
Stack 프레임은 메서드마다 생겨 메서드가 끝나면 통째로 사라진다. 블록 스코프는 이와 별개인 컴파일 시점의 규칙이다.
참조변수는 Stack에, 객체는 Heap에. 변수가 들고 있는 건 주소다.
static은 객체 없이도 이미 메모리에 있으므로 클래스명.멤버로 접근한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
같은 클래스로 만든 객체 100개는 메서드 코드도 100벌인가?
아닙니다. 메서드 코드는 Method Area에 딱 한 벌만 있고, 힙의 객체 100개는 각자의 필드 값만 갖습니다. 그래서 객체가 많아도 메서드 때문에 메모리가 늘지는 않습니다.
스택과 힙의 결정적인 차이는 무엇인가?
정리 방식입니다. 스택은 메서드가 끝나면 프레임째 자동으로 사라지고, 힙은 참조가 끊긴 뒤 GC가 치울 때까지 남습니다. 그래서 스택은 빠르고 예측 가능하지만 크기가 작고, 힙은 유연하지만 관리 비용이 듭니다.
💼 실무·코딩테스트에서는메모리 3영역은 면접 최빈출이고, 실무에서는 OutOfMemoryError를 만났을 때 어느 영역인지(Java heap space인지 Metaspace인지)에 따라 원인과 대응이 완전히 달라집니다.
03
참조(reference)란? — 변수가 들고 있는 건 값일까 주소일까
참조변수주소null얕은 복사
한 줄 요약기본타입 변수는 값 자체를 들고 있지만, 객체·배열·문자열 같은 참조타입 변수는 Heap에 있는 물건의 주소를 들고 있다 — 그래서 참조변수를 다른 변수에 대입하면 물건이 복사되는 게 아니라 주소만 하나 더 생긴다.
쉽게 말하면참조변수는 택배 송장 번호예요. 친구에게 송장 번호를 알려 줘도 물건이 두 개가 되지는 않습니다 — 같은 물건을 둘이 같이 보게 될 뿐이죠. 그래서 친구가 상자를 열어 내용물을 바꾸면 내가 봐도 바뀌어 있습니다. null은 송장 번호가 아직 없는 상태고, 그 상태로 물건을 열려고 하면 나는 오류가 NullPointerException입니다.
대입할 때 벌어지는 일
int a = 10; int b = a;는 값 10이 복사돼서 b를 바꿔도 a는 그대로입니다. 반면 int[] x = {1,2,3}; int[] y = x;는 주소가 복사되므로 y[0] = 99를 하면 x[0]도 99가 됩니다 — 애초에 같은 배열이니까요. 이것이 얕은 복사(shallow copy)이고, 진짜 별개로 만들려면 새 배열을 만들어 값을 하나씩 옮기는 깊은 복사가 필요합니다. → 🧱 09. 기본타입 vs 참조타입 · 🧱 17. 얕은 복사 · 깊은 복사
메서드에 넘길 때도 똑같다
자바는 항상 값을 복사해서 넘깁니다. 다만 참조타입일 때 "복사되는 값"이 주소라서, 메서드 안에서 배열[0] = 99처럼 내용물을 고치면 밖에도 반영되고, 배열 = new int[3]처럼 주소 자체를 갈아 끼우면 밖에는 아무 영향이 없습니다(복사된 주소만 바뀐 것). 이 구분이 시험에도 자주 나옵니다.
핵심 정리
기본타입 = 값 자체, 참조타입 = Heap 객체의 주소.
참조변수 대입은 주소 복사 → 둘이 같은 객체를 본다(얕은 복사).
메서드 안에서 내용을 고치면 밖에 반영되고, 주소를 갈아 끼우면 반영되지 않는다.
null = 아직 아무 객체도 가리키지 않는 상태.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
참조 변수가 '값을 담고 있지 않다'는 말을 정확히 설명해보세요.
변수 자리에는 객체의 주솟값이 들어 있고, 실제 데이터는 힙의 다른 곳에 있습니다. Student s는 학생 정보가 아니라 학생 정보가 있는 곳을 가리키는 화살표인 셈이죠. 그래서 s2 = s1은 화살표만 복사합니다.
null이 무엇을 뜻하는지 주소 관점에서 말해보세요.
아무것도 가리키지 않는 상태입니다. 화살표가 어디도 향하지 않는 것이죠. 그래서 s.getName()처럼 가리키는 곳을 따라가려 하면 갈 데가 없어 NullPointerException이 납니다.
💼 실무·코딩테스트에서는참조를 정확히 이해하면 얕은 복사·불변 객체·메모리 누수가 전부 한 줄기로 이해됩니다. 실무 버그의 상당수가 "복사한 줄 알았는데 같은 객체였다"에서 나오므로, 이 개념이 흔들리면 계속 같은 실수를 반복하게 됩니다.
04
가비지 컬렉션(GC) — 다 쓴 객체는 누가 치우나
GCHeap메모리 해제
한 줄 요약C 언어처럼 직접 메모리를 반납하지 않아도, 아무 참조변수도 가리키지 않게 된 Heap 객체는 JVM의 가비지 컬렉터가 알아서 회수한다 — 그래서 자바에는 free()가 없다.
쉽게 말하면창고에 물건을 넣어 두고 송장 번호를 버리면, 그 물건은 아무도 찾을 수 없는 물건이 됩니다. 창고 관리인(GC)은 주기적으로 돌면서 번호표가 하나도 안 붙은 물건을 내다 버립니다. 언제 버릴지는 관리인 마음이라 정확한 시점은 알 수 없습니다.
"참조가 끊긴다"는 게 무슨 뜻인가
D4_Constructor tv = new D4_Constructor(); tv = null;이면 방금 만든 TV 객체는 가리키는 변수가 하나도 없어져 회수 대상이 됩니다. 메서드 안에서 만든 객체도 마찬가지로, 메서드가 끝나면 Stack의 참조변수가 사라지므로 대부분 회수됩니다. 반대로 static 변수가 붙들고 있는 객체는 프로그램이 끝날 때까지 안 사라집니다 — static을 남발하면 메모리가 계속 차는 이유가 이것입니다.
핵심 정리
회수 대상 = 어떤 참조변수도 가리키지 않는 Heap 객체.
시점은 JVM이 정한다 — System.gc()도 "요청"일 뿐 보장이 아니다.
static이 붙들고 있는 객체는 회수되지 않는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
GC가 있어도 메모리 누수가 생길 수 있는 이유는?
참조가 남아 있으면 GC가 치우지 않기 때문입니다. 안 쓰는 객체를 컬렉션이나 static 필드가 계속 붙잡고 있으면 영원히 살아남죠. GC는 "쓸모없는 것"이 아니라 "아무도 참조하지 않는 것"만 치웁니다.
System.gc()를 부르면 즉시 정리되나?
아닙니다. "정리해 주면 좋겠다"는 요청일 뿐이고 실행 시점은 JVM이 정합니다. 오히려 성능을 해칠 수 있어 실무에서는 호출하지 않는 것이 원칙입니다.
💼 실무·코딩테스트에서는메모리 누수 진단은 실무 트러블슈팅의 중요한 축입니다. 힙 덤프를 떠서 어떤 객체가 왜 안 죽는지 참조 경로를 추적하죠. 원인은 대개 static 컬렉션, 리스너 해제 누락, 캐시 무한 증가 세 가지입니다.
B · 객체지향 기본 용어 — 비슷해 보이는 말들 구분하기
05
클래스 · 객체 · 인스턴스 — 셋은 무엇이 다른가
클래스객체인스턴스new
한 줄 요약클래스는 설계도(코드), 객체는 그 설계도로 new해서 Heap에 실제로 만들어진 것, 인스턴스는 "이 객체는 어느 클래스에서 나왔다"는 관계를 강조할 때 쓰는 같은 말이다.
쉽게 말하면붕어빵 틀 = 클래스, 구워 나온 붕어빵 하나하나 = 객체입니다. 그럼 인스턴스는? "이 붕어빵은 그 틀의 붕어빵이다"라고 말할 때 쓰는 표현이에요 — 물건이 다른 게 아니라 보는 각도가 다른 것입니다. 그래서 "객체"와 "인스턴스"는 실무에서 거의 구분 없이 섞어 씁니다.
객체를 두 개 만들면 무엇이 두 개가 되나
인스턴스 변수는 객체마다 따로 생기고, static 변수는 클래스에 하나만 있습니다. 4일차 실습이 정확히 이걸 확인한 코드였습니다 — classTest.number는 20, classTest2.number는 40으로 갈렸지만 staticNumber = 50 한 줄에 둘 다 50이 됐죠. 메서드도 마찬가지여서 인스턴스 메서드는 객체명.메서드(), static 메서드는 클래스명.메서드()로 부릅니다. → 🧱 01. 클래스 · 객체 · 인스턴스 · 📅 4일차 수업 진도
핵심 정리
클래스 = 설계도, 객체 = new로 Heap에 만들어진 실체, 인스턴스 = 그 관계를 강조한 표현.
인스턴스 변수는 객체마다, static 변수는 클래스마다 하나.
클래스 정보는 그 클래스가 처음 사용될 때(클래스 로딩) Method Area에 한 번 올라가지만, 객체는 new를 해야 생긴다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
클래스가 '틀'이라면 static 멤버는 그 비유에서 무엇인가?
틀 자체에 붙어 있는 정보입니다. 붕어빵 틀로 만든 붕어빵마다 다른 게 인스턴스 멤버라면, "이 틀로 몇 개를 구웠는가" 같은 정보는 틀에 붙습니다. 그래서 객체를 안 만들어도 접근할 수 있습니다.
객체 하나가 만들어질 때 메모리에서 벌어지는 일을 순서대로 말해보세요.
① 힙에 공간 할당 → ② 필드를 기본값으로 초기화(0, null 등) → ③ 생성자 실행으로 초기값 채우기 → ④ 그 주솟값을 변수에 대입. 이 순서를 알면 "생성자에서 필드를 쓸 수 있는 이유"가 자연히 설명됩니다.
💼 실무·코딩테스트에서는객체지향의 출발점이라 모든 후속 개념(상속·다형성·인터페이스)이 여기 얹힙니다. 실무 설계 논의에서 "이건 클래스로 만들 가치가 있나, 그냥 값이면 되나"를 판단하는 감각이 여기서 나옵니다.
06
멤버필드 · 지역변수 · 매개변수 — 변수의 세 가지 신분
멤버필드지역변수매개변수초기화
한 줄 요약클래스 바로 아래에 선언한 멤버필드는 객체가 살아 있는 동안 유지되고 자동으로 기본값이 들어가지만, 메서드 안의 지역변수는 메서드가 끝나면 사라지고 직접 초기화하지 않으면 쓸 수 없다. 매개변수는 호출할 때 값을 받아 오는 특수한 지역변수다.
쉽게 말하면멤버필드는 집에 놓아둔 가구(내가 사는 동안 계속 있음), 지역변수는 여행 갈 때 챙긴 짐(돌아오면 없어짐), 매개변수는 문 앞에서 건네받은 택배(들어오면서 손에 쥐고 온 것)입니다.
가장 자주 걸리는 차이 — 자동 초기화
멤버필드는 선언만 해도 int는 0, boolean은 false, 참조타입은 null이 자동으로 들어갑니다. 그런데 지역변수에는 이런 자동 초기화가 없습니다 — int sum; sum += 10;은 "변수 sum을 초기화하지 않았을 수 있습니다"라는 컴파일 에러가 납니다. 4일차의 int res = 0;처럼 0으로라도 먼저 채워 두는 습관이 여기서 나옵니다.
이름이 겹칠 때
생성자·setter에서 public void setSize(int size){ this.size = size; }처럼 매개변수와 멤버필드 이름이 같은 경우가 매우 흔합니다. 이때 그냥 size는 가까운 쪽(매개변수)을 가리키므로, 멤버필드를 지목하려면 this.를 붙여야 합니다. → this와 super
핵심 정리
멤버필드 = 객체와 함께 Heap에, 지역변수·매개변수 = Stack에.
멤버필드는 자동 초기화(0/false/null), 지역변수는 직접 초기화해야 쓸 수 있다.
이름이 겹치면 가까운 쪽(매개변수)이 이기고, 멤버필드는 this.로 지목한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
멤버필드는 초기화 안 해도 되는데 지역변수는 왜 강제되나?
멤버필드는 객체 생성 시 기본값으로 자동 초기화되지만(0·null·false), 지역변수는 스택에 자리만 잡히고 값이 채워지지 않기 때문입니다. 쓰레기 값을 읽는 것을 막으려고 컴파일러가 강제하는 것입니다.
매개변수는 셋 중 어디에 가장 가까운가?
지역변수입니다. 메서드가 실행되는 동안만 스택에 존재하고 끝나면 사라집니다. 다만 호출할 때 값이 이미 채워져 들어온다는 점만 다르죠 — 그래서 초기화를 따로 하지 않아도 됩니다.
💼 실무·코딩테스트에서는변수의 생명주기를 아는 것이 디버깅의 기초입니다. "이 값이 왜 초기화됐지?"는 대개 지역변수를 매번 새로 만들고 있거나, 반대로 "왜 이전 값이 남아 있지?"는 멤버필드나 static이라 그렇습니다.
07
생성자(Constructor) — 객체가 태어나는 순간 딱 한 번
생성자기본 생성자오버로딩this()
한 줄 요약생성자는 클래스명과 이름이 같고 반환타입이 없는 특수한 메서드로, new하는 순간 딱 한 번 실행돼 객체의 초기 상태를 정한다. 매개변수가 있는 생성자를 하나라도 직접 만들면 기본 생성자는 더 이상 자동으로 생기지 않는다.
쉽게 말하면붕어빵을 굽는 순간 "팥이요, 슈크림이요"를 정하는 주문서입니다. 아무 말 없이 주문하면 기본 맛(기본 생성자), 크기만 말하면 크기만 바꿔서, 크기와 맛을 다 말하면 둘 다 바꿔서 굽습니다 — 이렇게 주문서 양식을 여러 개 만들어 두는 것이 생성자 오버로딩입니다.
세 가지 규칙
이름은 클래스명과 같고, 반환타입을 쓰지 않는다.public void D4_Constructor()처럼 void를 붙이면 그건 생성자가 아니라 이름이 우연히 같은 일반 메서드가 됩니다 — 찾기 어려운 실수 1순위입니다.
직접 만든 생성자가 하나라도 있으면 기본 생성자는 자동 생성되지 않는다. 그래서 new D2_ClassTest()도 쓰고 싶다면 인자 없는 생성자를 명시적으로 선언해야 합니다.
첫 줄에는 this(...)나 super(...) 중 하나만 올 수 있습니다(둘 다 "첫 줄" 자리를 요구하므로 함께 쓸 수 없음). 아무것도 안 쓰면 컴파일러가 super()를 넣어 줍니다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
this가 가리키는 두 가지를 구분해 말해보세요.
① this.필드 — 현재 객체 자신을 가리켜 지역변수와 필드를 구분할 때 ② this() — 같은 클래스의 다른 생성자를 호출할 때. 형태는 비슷하지만 완전히 다른 기능입니다.
super()를 안 써도 부모 생성자가 실행되는 이유는?
컴파일러가 자동으로 super()를 첫 줄에 넣어 주기 때문입니다. 부모가 먼저 초기화되어야 자식이 부모 필드를 쓸 수 있으니까요. 다만 부모에 기본 생성자가 없으면 자동 삽입이 실패해 직접 super(인자)를 써야 합니다.
💼 실무·코딩테스트에서는상속 계층에서 생성자 호출 순서는 면접에서 자주 나옵니다 — 부모부터 위에서 아래로 실행됩니다. 실무에서는 부모에 기본 생성자가 없어 컴파일 에러가 나는 상황을 자주 만나는데, 원인이 바로 이 자동 super()입니다.
09
캡슐화 · 상속 · 다형성 — OOP 3대 개념이 실제로 하는 일
캡슐화상속다형성getter/setter
한 줄 요약캡슐화는 필드를 private으로 감추고 메서드라는 창구로만 다루게 하는 것, 상속은 이미 만든 클래스를 물려받아 확장하는 것, 다형성은 부모 타입 하나로 여러 자식을 똑같이 다루는 것이다.
쉽게 말하면캡슐화는 은행 금고 — 돈을 직접 못 만지고 창구 직원(메서드)을 거치게 합니다. 상속은 가업을 물려받아 거기에 내 것을 얹는 것. 다형성은 리모컨 하나로 TV·에어컨·선풍기를 모두 켜는 것 — "전원 켜기"라는 같은 버튼을 눌러도 기기마다 다르게 동작합니다.
캡슐화를 왜 하는가 — 조건을 끼워 넣을 수 있다
필드를 public으로 열어 두면 누구나 아무 값이나 넣습니다. private int size;로 막고 setSize(int size)를 거치게 하면 그 안에 검사 코드를 넣을 수 있습니다(음수 거부, 비밀번호 확인 등). 4일차 getSize(int pw)가 정확히 그 예였죠 — 이것이 캡슐화를 하는 진짜 이유입니다. → 🧱 04. 접근 제한자 4가지
다형성이 없으면 어떻게 되는가
도형 10종을 그린다고 할 때 다형성이 없으면 if (원이면) 원그리기 else if (사각형이면)…이 끝없이 늘어납니다. Shape라는 부모 타입으로 배열에 담아 두고 shape.draw()만 부르면, 실제 객체가 무엇이냐에 따라 알아서 각자의 draw가 실행됩니다. 이걸 가능하게 하는 것이 오버라이딩이고, 그래서 상속 → 오버라이딩 → 다형성은 한 세트로 배웁니다. → 🧱 18. OOP 3대 개념과 상속 · 🧱 19. 오버라이딩과 VMI(가상 메서드 호출)
핵심 정리
캡슐화 = private 필드 + getter/setter — 검사 조건을 끼워 넣기 위해서.
상속 = extends로 물려받아 확장. 자바는 클래스 다중 상속을 지원하지 않는다.
다형성 = 부모 타입으로 자식들을 똑같이 다루는 것 — 오버라이딩이 있어야 성립한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
캡슐화·상속·다형성 중 하나만 남긴다면 무엇이 가장 중요할까? 왜?
정답은 없지만 다형성을 꼽는 사람이 많습니다. 새 타입이 추가돼도 기존 코드를 고치지 않게 해 주는 것이 유지보수에 가장 큰 이득이기 때문입니다. 캡슐화는 변경 범위를 좁히고, 상속은 중복을 줄이는 데 기여합니다.
상속을 쓰지 않고도 다형성을 구현할 수 있나?
있습니다.인터페이스만으로 충분하죠. 오히려 현대 설계는 "상속보다 인터페이스 + 조합"을 권합니다 — 상속은 부모 구현에 묶여 유연성이 떨어지기 때문입니다.
💼 실무·코딩테스트에서는면접에서 3대 개념을 정의로 답하면 평범하고, "그래서 무엇이 좋아지는가"로 답하면 좋습니다. 실무에서 이 개념들은 SOLID 원칙으로 구체화되어 나타나며, 스프링 같은 프레임워크가 그 위에서 동작합니다.
10
패키지와 import — 이름이 곧 경로다
packageimportjava.langFQCN
한 줄 요약package는 클래스가 들어 있는 폴더 경로를 파일 첫 줄에 적어 둔 것이고, import는 다른 패키지의 클래스를 짧은 이름으로 부르게 해 달라는 선언일 뿐 — 코드를 복사해 오는 게 아니다.
쉽게 말하면같은 반에 김민수가 셋이면 "1반 김민수", "2반 김민수"로 불러야 하죠. 패키지가 그 반 이름입니다. import는 "우리 얘기에서 '민수'라고 하면 2반 민수를 말하는 거야"라고 미리 약속해 두는 것 — 그래야 매번 반 이름을 붙이지 않아도 됩니다.
세 가지만 기억하면 된다
package는 파일의 맨 첫 줄이고 폴더 구조와 정확히 일치해야 합니다(hk.edu20260806.day04 → hk/edu20260806/day04).
java.lang은 import가 필요 없습니다.String·System·Object·Integer를 그냥 쓸 수 있는 이유가 이것입니다.
import java.util.*;의 *는 그 패키지 바로 아래 클래스만 포함합니다 — 하위 패키지(java.util.function)까지 따라오지는 않습니다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
패키지가 단순한 '폴더 정리'가 아닌 이유는?
접근 제한(default)의 경계이자 클래스의 전체 이름 일부이기 때문입니다. 같은 패키지면 제한자 없이도 접근되고, com.a.User와 com.b.User는 이름이 같아도 다른 클래스입니다. 폴더가 곧 이름 공간인 셈이죠.
import java.util.* 가 성능에 영향을 주나?
주지 않습니다. import는 컴파일 시점에 이름을 풀어 주는 역할일 뿐, 실행 파일에 뭔가를 더 넣지 않습니다. 다만 같은 이름의 클래스가 여러 패키지에 있으면 충돌하므로 실무에서는 명시적 import를 씁니다.
💼 실무·코딩테스트에서는실무 프로젝트의 패키지 구조가 곧 아키텍처입니다 — 계층형(controller/service/repository)이냐 도메인형(user/order/payment)이냐로 나뉘죠. 최근에는 도메인별로 묶는 방식이 선호되는 추세입니다.
C · 자주 헷갈리는 짝 — 시험에 가장 많이 나오는 형태
11
== vs equals() — 주소를 볼 것인가 내용을 볼 것인가
==equalsString poolhashCode
한 줄 요약==는 주소가 같은가(같은 객체인가)를 보고, equals()는 내용이 같은가를 본다 — 단 equals()도 재정의하지 않으면 Object의 기본 구현이 그대로 ==와 같은 주소 비교를 한다.
쉽게 말하면쌍둥이 두 명을 두고 ==는 "같은 사람이야?"라고 묻고(아니오), equals()는 "생김새가 같아?"라고 묻습니다(예). 기본타입(int 등)은 애초에 주소가 없으니 ==가 곧 값 비교라 헷갈릴 일이 없고, 문제는 항상 참조타입에서 생깁니다.
문자열이 특히 헷갈리는 이유 — String pool
4일차 실습 결과가 이걸 그대로 보여 줬습니다. String s = "a"; String s2 = "a";는 s == s2가 true인데, 리터럴 문자열은 String pool에 하나만 만들어 두고 돌려쓰기 때문입니다. 반면 String s3 = new String("a");는 Heap에 새 객체를 만들므로 s == s3는 false, s.equals(s3)는 true가 됩니다. 그래서 문자열 비교는 무조건 equals()가 정답입니다 — ==가 우연히 맞는 경우가 있어서 더 위험합니다. → 🧱 12. String pool 메모리 구조 · 🧱 14. == 와 equals의 차이
내가 만든 클래스는 equals()를 직접 만들어야 한다
Object의 기본 equals()는 주소를 비교합니다. 그래서 내용이 같은 내 객체 둘을 비교하면 false가 나오죠. 내용 비교를 원하면 equals()를 오버라이딩해야 하고, 이때 hashCode()도 함께 재정의해야 합니다 — HashSet·HashMap이 hashCode로 먼저 자리를 찾고 그다음 equals로 확인하는 순서로 동작하기 때문에, 둘이 어긋나면 "분명 넣었는데 contains()가 false"인 상황이 생깁니다.
핵심 정리
== = 주소(같은 객체인가), equals() = 내용 — 단 재정의했을 때만.
리터럴 문자열은 pool에서 공유되어 ==도 true, new String()은 false.
문자열 비교는 항상 equals()를 쓴다.
equals()를 재정의하면 hashCode()도 같이 재정의한다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
문자열 리터럴끼리 ==가 true인 것이 왜 오히려 위험한가?
테스트에서는 통과하다가 실제 데이터에서 실패하기 때문입니다. 코드에 직접 쓴 리터럴은 pool을 공유해 true지만, 입력받거나 DB에서 온 문자열은 새 객체라 false가 됩니다. "되는 줄 알았는데 안 되는" 전형적인 패턴이죠.
equals를 재정의할 때 지켜야 할 규칙 중 하나를 말해보세요.
대칭성이 대표적입니다 — a.equals(b)가 true면 b.equals(a)도 true여야 합니다. 이 외에 반사성·추이성·일관성·null 처리 규칙이 있고, 깨뜨리면 컬렉션이 이상하게 동작합니다.
💼 실무·코딩테스트에서는실무에서 == 문자열 비교는 리뷰 즉시 지적되고, equals/hashCode 미재정의는 컬렉션 버그로 이어집니다. 면접에서는 "equals만 재정의하면 무슨 일이 생기나"를 묻는 형태로 자주 나옵니다.
12
오버로딩 vs 오버라이딩 — 이름만 비슷한 완전히 다른 것
오버로딩오버라이딩@Override다형성
한 줄 요약오버로딩(Overloading)은 한 클래스 안에서 같은 이름의 메서드를 매개변수만 다르게 여러 개 만드는 것이고, 오버라이딩(Overriding)은 부모에게 물려받은 메서드를 자식이 똑같은 모양으로 다시 쓰는 것이다.
쉽게 말하면오버로딩은 같은 이름의 주문 메뉴가 여러 개인 것 — "아메리카노(톨)", "아메리카노(그란데)"처럼 주문 방식만 다른 형제 메뉴입니다. 오버라이딩은 물려받은 레시피를 내 가게 방식으로 덮어쓰는 것 — 메뉴 이름은 그대로인데 만드는 법이 바뀝니다.
구분하는 기준 딱 하나 — "어디에 있는가"
오버로딩 — 같은 클래스 안. 매개변수의 개수·타입·순서가 달라야 하고, 반환타입만 다른 건 오버로딩이 아닙니다(컴파일 에러). 생성자 오버로딩도 같은 규칙입니다.
오버라이딩 — 부모-자식 관계. 메서드 이름·매개변수가 같아야 하고 반환타입도 같아야 합니다(단, 참조타입이면 부모 반환타입의 자식 타입으로 좁히는 것은 허용 — 공변 반환). 또 접근 범위를 부모보다 좁게 만들 수 없습니다(public을 private으로 못 바꿈).
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
오버로딩과 오버라이딩이 결정되는 시점이 각각 언제인가?
오버로딩은 컴파일 시점(인자를 보고 어느 메서드인지 정함), 오버라이딩은 실행 시점(실제 객체 타입을 보고 정함)입니다. 그래서 오버라이딩을 동적 바인딩이라 부르고, 이것이 다형성의 실체입니다.
오버라이딩할 때 접근 제한자를 더 좁게 만들 수 없는 이유는?
부모 타입으로 쓰던 코드가 깨지기 때문입니다. 부모에서 public이던 메서드를 자식이 private으로 바꾸면, 부모 타입 변수로 호출하던 코드가 갑자기 접근 불가가 되죠. 그래서 같거나 더 넓게만 허용됩니다.
💼 실무·코딩테스트에서는이 둘의 구분은 자바 면접 기본 중의 기본입니다. 이름이 비슷해 헷갈리기 쉬운데, "오버로딩은 편의, 오버라이딩은 다형성"으로 목적이 완전히 다르다는 것을 기억하면 정리됩니다.
13
static vs 인스턴스 — 클래스의 것인가 객체의 것인가
static클래스 변수인스턴스 변수공유
한 줄 요약static이 붙으면 클래스에 하나만 존재해 모든 객체가 공유하고 객체 없이 클래스명.멤버로 쓸 수 있으며, 붙지 않으면 객체마다 따로 생겨 new 후에만 쓸 수 있다.
쉽게 말하면학교로 치면 static은 교문에 붙은 학교 이름(학생이 몇 명이든 하나뿐, 학생이 없어도 이미 붙어 있음)이고, 인스턴스 멤버는 학생 각자의 이름표(학생이 있어야 존재하고 사람마다 다름)입니다.
가장 많이 걸리는 규칙 — static은 non-static을 직접 못 쓴다
main이 static이라 이미 메모리에 있는데, 인스턴스 메서드는 객체가 만들어져야 생기므로 아직 없습니다. 없는 것을 부를 수는 없으니 컴파일러가 막는 것이죠(해당 에러 카드). 해결은 두 가지 — 객체를 만들어참조변수.메서드()로 부르거나, 그 메서드도 static으로 만들거나. 3일차 D2_MethodTest가 앞의 방식, 1일차 testMethod()가 뒤의 방식이었습니다. 같은 이유로 static 메서드 안에서는 this를 쓸 수 없습니다.
언제 static을 쓰나
객체마다 달라질 이유가 없는 것 — 상수(public static final), 유틸리티 메서드(Math.random()처럼 상태 없이 계산만 하는 것), 공유 카운터 정도입니다. 반대로 객체마다 값이 달라야 하는 것에 static을 붙이면 모든 객체가 같은 값을 보게 되는 버그가 됩니다. → 🧱 05. static vs non-static과 메모리 3영역
핵심 정리
static = 클래스에 하나(공유), 인스턴스 = 객체마다 따로.
static 멤버는 객체 없이클래스명.멤버로 접근한다.
static 안에서는 인스턴스 멤버와 this를 직접 쓸 수 없다.
static 변수가 붙들고 있는 객체는 GC 대상이 되지 않는다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
static 메서드를 오버라이딩할 수 있나?
없습니다. static은 클래스에 속하고 컴파일 시점에 결정되므로 다형성이 적용되지 않습니다. 자식에 같은 이름의 static 메서드를 만들면 오버라이딩이 아니라 숨기기(hiding)가 되어, 변수의 타입에 따라 어느 것이 불릴지 결정됩니다.
유틸리티 클래스를 static으로 만드는 것이 적절한 이유는?
상태가 없기 때문입니다. Math.abs()는 어떤 객체에 속할 이유가 없죠 — 입력만 받아 결과를 내니까요. 상태를 갖지 않는 순수한 기능이면 static이 자연스럽고, 상태가 필요해지는 순간 인스턴스로 바꿔야 합니다.
💼 실무·코딩테스트에서는실무에서 static 남용은 테스트를 어렵게 만드는 주범입니다. 가짜 구현으로 갈아 끼울 수 없고, 테스트끼리 상태를 공유해 실행 순서에 따라 결과가 달라지기 때문입니다. 그래서 요즘은 static 유틸보다 주입 가능한 객체를 선호합니다.
14
기본타입 vs 참조타입 — 그리고 Wrapper 클래스
기본타입 8종참조타입Wrapper오토박싱
한 줄 요약기본타입은 byte·short·int·long·float·double·char·boolean 8가지뿐이고 값을 직접 담으며 null이 될 수 없다. 그 밖의 모든 타입(클래스·배열·인터페이스·String)은 참조타입으로, 주소를 담고 null이 될 수 있다.
쉽게 말하면기본타입은 지갑에 든 현금(그 자체가 값), 참조타입은 은행 계좌번호가 적힌 카드(값이 있는 곳을 가리킴)입니다. 현금은 "없음"이라는 상태가 없지만(0은 0원이라는 값), 카드는 발급조차 안 된 상태(null)가 있습니다.
Wrapper 클래스가 필요한 이유
컬렉션(ArrayList 등)은 객체만 담을 수 있어서int를 그대로 넣지 못합니다. 그래서 int를 감싼 Integer, double을 감싼 Double 같은 Wrapper 클래스가 있습니다. 요즘은 오토박싱/언박싱으로 list.add(10)처럼 그냥 써도 자동 변환되지만, Integer는 참조타입이라 null이 될 수 있다는 차이는 남아 있습니다 — null인 Integer를 int에 대입하는 순간 NullPointerException이 납니다.
Wrapper의 또 다른 쓸모는 문자열 → 숫자 변환입니다. Integer.parseInt("123")이 대표적이고, Scanner로 받은 한 줄을 숫자로 바꾸는 Integer.parseInt(sc.nextLine())이 바로 이 형태입니다. → 🧱 10. final · 상수와 Wrapper 클래스 · ☕ 04. 기본타입 8가지
핵심 정리
기본타입 8종만 값을 직접 담고, 나머지는 전부 참조타입이다.
기본타입은 null이 될 수 없고, 참조타입은 될 수 있다.
컬렉션에는 객체만 들어가므로 int → Integer Wrapper가 필요하다(오토박싱).
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
기본타입과 참조타입을 나눈 이유가 무엇일까?
성능 때문입니다. 모든 것이 객체라면 int 하나에도 객체 헤더와 힙 할당 비용이 붙죠. 그래서 자주 쓰는 작은 값은 값 그대로 두는 기본타입으로 남겨 뒀습니다(지역변수면 스택에, 객체의 필드면 그 객체 안 — 힙에 — 값이 바로 들어 있습니다). 대신 컬렉션에 못 담는 불편이 생겨 Wrapper가 필요해졌습니다.
오토박싱이 성능 문제를 일으키는 상황은?
반복문 안에서 Wrapper로 누적할 때입니다. Long sum = 0L;로 두고 1억 번 더하면 매번 새 Long 객체가 생성되어 엄청나게 느려집니다. 기본타입 long으로 선언하면 해결됩니다.
💼 실무·코딩테스트에서는이 오토박싱 성능 함정은 면접과 실무 모두에서 유명합니다. 코딩테스트에서도 큰 반복 안에서 Wrapper를 쓰면 시간 초과가 날 수 있으니, 누적 변수는 기본타입으로 선언하는 습관이 좋습니다.
15
String vs StringBuilder — 문자열은 왜 못 바꾸나
불변immutableStringBuilderStringBuffer
한 줄 요약String은 한 번 만들어지면 절대 바뀌지 않아서(불변) s += "a"를 할 때마다 새 객체가 만들어진다. 반복문 안에서 문자열을 이어 붙일 때는 내용을 직접 고칠 수 있는 StringBuilder를 써야 한다.
쉽게 말하면String은 인쇄된 종이예요. 한 글자를 더하려면 새 종이에 전부 다시 인쇄해야 하고, 옛 종이는 버려집니다. 1000번 반복하면 종이 1000장을 버리는 셈이죠. StringBuilder는 화이트보드라서 옆에 계속 이어 쓰면 됩니다.
불변이라 좋은 점도 있다
바뀌지 않기 때문에 String pool에서 안심하고 공유할 수 있고, 여러 스레드가 동시에 봐도 안전합니다. "a" + "b"가 "ab"가 되는 건 원래 문자열이 바뀐 게 아니라 새 문자열이 생긴 것이라는 점만 잊지 마세요. StringBuilder와 StringBuffer는 기능이 같고, StringBuffer만 동기화(thread-safe)되어 조금 느립니다 — 혼자 쓰는 코드면 StringBuilder가 정답입니다. → 🧱 12. String의 특징과 pool · 🧱 15. StringBuffer · StringBuilder
핵심 정리
String은 불변 — 이어 붙일 때마다 새 객체가 생긴다.
반복문 안에서 문자열 조립 → StringBuilder.append().
StringBuilder(빠름, 단일 스레드) vs StringBuffer(동기화, 느림).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
String이 불변인데도 s = s + "a" 가 되는 것처럼 보이는 이유는?
기존 객체를 바꾼 게 아니라 새 객체를 만들어 변수가 그쪽을 가리키게 한 것입니다. 원래 객체는 그대로 남아 있다가 참조가 끊겨 GC 대상이 되죠. "변수는 바뀌지만 객체는 안 바뀐다"가 핵심입니다.
문자열 연결이 항상 느린가?
아닙니다."a" + "b"처럼 컴파일 시점에 확정되는 것은 미리 합쳐지고, 한 줄 안의 연결도 컴파일러가 StringBuilder로 바꿔 줍니다. 문제는 반복문 안입니다 — 회차마다 새 StringBuilder가 만들어지기 때문입니다.
💼 실무·코딩테스트에서는"String vs StringBuilder"는 면접 단골이고, "반복문 안에서만 문제"라는 뉘앙스까지 답하면 깊이가 드러납니다. 코딩테스트에서는 대량 출력·문자열 누적에 StringBuilder가 사실상 필수입니다.
16
배열 vs 컬렉션 — 크기를 미리 정해야 하는가
배열ArrayListlengthsize()
한 줄 요약배열은 만들 때 크기를 정하면 끝까지 못 바꾸고 기본타입도 담을 수 있는 반면, ArrayList 같은 컬렉션은 크기가 자동으로 늘어나고 객체만 담을 수 있으며 추가·삭제 메서드를 제공한다.
쉽게 말하면배열은 칸 수가 정해진 계란판(10칸짜리를 샀으면 11개는 못 담음), 컬렉션은 지퍼백(넣는 대로 늘어남)입니다. 계란판이 더 빠르고 가볍지만, 몇 개가 들어올지 모를 때는 지퍼백이 편하죠.
헷갈리는 이름 세 가지
배열은 arr.length(괄호 없는 필드), 컬렉션은 list.size()(메서드), 문자열은 str.length()(메서드) — 셋이 미묘하게 달라서 시험 단골입니다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
배열의 크기를 나중에 못 바꾸는 이유는?
메모리에 연속된 공간을 통째로 잡아 두기 때문입니다. 뒤쪽 공간이 이미 다른 데 쓰이고 있을 수 있어 그 자리에서 늘릴 수가 없습니다. ArrayList는 가득 차면 더 큰 배열을 새로 만들어 통째로 복사하는 방식으로 이를 우회합니다.
ArrayList가 내부적으로 배열이라면 무엇이 다른가?
크기 관리와 편의 메서드를 대신해 주는 것입니다. 용량이 차면 자동으로 1.5배 늘려 복사하고, add·remove·contains를 제공하죠. 편한 대신 복사 비용과 Wrapper 박싱 비용이 따라옵니다.
💼 실무·코딩테스트에서는코딩테스트에서 크기를 미리 알면 배열, 모르면 ArrayList가 기본 전략입니다. 실무에서는 거의 컬렉션을 쓰지만, 대량 수치 연산은 배열이 훨씬 빠르다는 점 때문에 성능이 중요한 코드에서는 배열이 남아 있습니다.
17
추상 클래스 vs 인터페이스 — 둘 다 "미완성"인데 뭐가 다른가
abstractinterfaceimplements다중 구현
한 줄 요약추상 클래스는 완성된 코드와 미완성 메서드를 섞어 물려주는 부모이고(extends, 하나만), 인터페이스는 "이 기능들은 반드시 만들어라"는 약속 목록이다(implements, 여러 개 가능).
쉽게 말하면추상 클래스는 반쯤 지어진 집을 물려받는 것 — 골조와 배관은 이미 있고 내부 인테리어만 내가 채웁니다. 인터페이스는 계약서예요 — "전원 켜기, 끄기 기능은 반드시 만들 것"이라고 적혀만 있고 내용은 전부 내가 씁니다. 집은 하나만 물려받을 수 있지만 계약서는 여러 장에 서명할 수 있습니다.
고르는 기준
공통 코드를 물려주고 싶다 → 추상 클래스. 필드와 구현된 메서드를 가질 수 있습니다.
서로 상관없는 클래스들에 같은 기능을 강제하고 싶다 → 인터페이스. 상속 계층과 무관하게 붙일 수 있고, 여러 개를 동시에 구현할 수 있습니다(자바가 클래스 다중 상속을 막은 대신 열어 둔 길). 자바 8부터는 인터페이스에도 default 메서드(본문이 있는 메서드)를 넣을 수 있어, 구현 클래스가 따로 만들지 않으면 그 기본 동작을 그대로 씁니다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
한 클래스가 인터페이스는 여러 개 구현할 수 있는데 클래스는 하나만 상속되는 이유는?
다중 상속의 모호성(다이아몬드 문제) 때문입니다. 두 부모가 같은 이름의 구현을 갖고 있으면 어느 것을 쓸지 정할 수 없죠. 인터페이스는 원래 구현이 없어서 이 문제가 없었습니다.
그렇다면 default 메서드가 생긴 뒤에는 그 문제가 다시 생기지 않나?
생길 수 있습니다. 그래서 자바는 두 인터페이스에 같은 시그니처의 default가 있으면 컴파일 에러를 내고, 구현 클래스가 직접 재정의해 어느 쪽을 쓸지 밝히도록 강제합니다.
💼 실무·코딩테스트에서는실무 설계는 인터페이스 중심입니다 — "구현이 아니라 인터페이스에 의존하라"는 원칙 덕분에 테스트와 교체가 쉬워지죠. 면접에서는 "언제 추상 클래스를 쓰고 언제 인터페이스를 쓰나"를 자주 묻습니다.
D · 🚨 에러 메시지 사전 — 빨간 글씨를 그대로 찾아보기
18
NullPointerException — "가리키는 게 없는데 열려고 했다"
NPEnull실행 중 에러
한 줄 요약null인 참조변수에 대고 필드를 읽거나 메서드를 부를 때 나는, 자바에서 가장 흔한 실행 중(runtime) 에러다 — 컴파일은 멀쩡히 되고 실행할 때 터진다는 게 특징이다.
쉽게 말하면택배 송장 번호가 비어 있는데 물건을 열어 보려고 한 상황입니다. 번호가 없으니 찾아갈 곳도 없죠. 그래서 고치는 방향은 늘 둘 중 하나예요 — 번호를 채워 주거나(객체를 만들어 주거나), 번호가 있는지 먼저 확인하거나.
자주 나오는 상황
객체를 new 하지 않고 선언만 한 뒤 바로 사용 — Scanner sc = null; sc.nextInt(); (필드로 선언만 해 둔 경우도 기본값이 null이라 같다. 지역 변수를 Scanner sc;처럼 초기화 없이 쓰면 NPE가 아니라 "might not have been initialized" 컴파일 에러가 난다)
배열은 만들었지만 안의 객체는 안 만든 경우 — String[] arr = new String[3]; arr[0].length();(칸은 3개지만 전부 null).
메서드가 null을 돌려줬는데 그대로 이어서 쓴 경우 — map.get("없는키").length().
null인 Integer를 int에 대입(언박싱 순간 터짐).
고치는 법
에러 메시지의 줄 번호부터 보세요 — 자바 14부터 생긴 기능(15부터 기본으로 켜짐)으로, Cannot invoke "String.length()" because "str" is null처럼 무엇이 null인지까지 알려 줍니다. 변수 이름 대신 "<local1>"처럼 나오는 것은 디버그 정보 없이 컴파일했을 때이고, 이클립스 같은 IDE에서 실행하면 보통 변수명이 그대로 보입니다. 그다음 ① 그 변수를 new로 만들어 주거나, ② if (x != null)로 감싸거나, ③ 애초에 null을 반환하지 않도록(빈 문자열·빈 리스트를 돌려주도록) 설계를 바꿉니다. 문자열 비교라면 "확인".equals(input)처럼 확실한 값을 앞에 두는 방법도 자주 씁니다.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
NPE가 났을 때 스택 트레이스에서 무엇을 먼저 봐야 하나?
가장 위의 줄(내 코드가 나오는 첫 줄)입니다. 그 줄에서 점(.)을 찍은 대상 중 무엇이 null인지 찾으면 됩니다. a.getB().getC()처럼 이어 쓰면 어느 것이 null인지 안 보이므로, 나눠 쓰면 디버깅이 쉬워집니다.
NPE를 예방하는 방법을 두 가지만 말해보세요.
① null을 반환하지 않기 — 빈 리스트나 Optional을 돌려주면 호출하는 쪽이 안전합니다. ② 상수를 앞에 두고 비교 — "ADMIN".equals(role)은 role이 null이어도 안전합니다.
💼 실무·코딩테스트에서는NPE는 실무에서 가장 흔한 런타임 예외이고, 그래서 Optional·@NonNull·정적 분석 도구가 발전했습니다. "10억 달러짜리 실수"라는 null의 별명도 유명하니 알아 두면 좋습니다.
19
ArrayIndexOutOfBoundsException — 없는 칸을 꺼내려 했다
배열인덱스length반복문
한 줄 요약배열의 인덱스는 0부터 length - 1까지인데 그 범위를 벗어난 칸에 접근했을 때 나는 실행 중 에러다 — 대부분 반복문 조건의 <와 <= 하나 차이에서 생긴다.
쉽게 말하면10칸짜리 사물함에서 11번 칸을 열려고 한 것입니다. 자바는 칸 번호를 0번부터 매기기 때문에, 10칸이면 번호는 0~9이고 10번은 없습니다.
거의 항상 이 둘 중 하나
for (int i = 0; i <= arr.length; i++) — <=를 <로 바꾸면 끝납니다. 가장 흔한 원인.
배열 크기를 고정 숫자로 써 놓고 배열 길이를 바꾼 경우 — arr.length를 쓰면 다시는 안 틀립니다.
메시지에 나오는 Index 10 out of bounds for length 10은 "10번을 찾았는데 길이가 10이다"라는 뜻이라, 숫자 두 개가 같으면 딱 한 칸 넘은 것임을 바로 알 수 있습니다. 인덱스만 다루는 실수를 피하려면 향상된 for문(for (int n : arr))을 쓰는 것도 좋은 방법입니다. → 🧱 16. 배열 · 🧱 17. 향상된 for문
핵심 정리
유효 인덱스는 0 ~ length-1.
반복문 조건은 i < arr.length(<= 아님).
Index N out of bounds for length N = 딱 한 칸 넘긴 것.
문자열에도 같은 계열의 StringIndexOutOfBoundsException이 있다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
인덱스가 0부터 시작하는 것이 이 에러와 어떻게 연결되나?
길이가 5인 배열의 유효 인덱스가 0~4이기 때문입니다. arr[5]는 다섯 번째가 아니라 여섯 번째를 요구하는 것이라 범위를 벗어나죠. 반복문 조건을 i <= arr.length로 쓰면 마지막에 반드시 이 에러가 납니다.
이 에러를 예방하는 가장 확실한 방법은?
인덱스를 직접 다루지 않는 것입니다 — 향상된 for문이나 Stream을 쓰면 범위를 넘을 일이 없죠. 인덱스가 꼭 필요하면 조건을 <로 쓰는지 확인하는 습관을 들입니다.
💼 실무·코딩테스트에서는코딩테스트에서 가장 흔한 런타임 에러입니다. 특히 2차원 배열의 행·열을 바꿔 쓰거나, i-1·i+1로 접근할 때 경계 처리를 빠뜨려 발생합니다. 경계 접근이 있으면 첫·마지막 원소를 따로 확인하는 습관이 필요합니다.
20
non-static method cannot be referenced from a static context
staticmain컴파일 에러
한 줄 요약main처럼 static인 자리에서 객체가 있어야 존재하는 메서드·필드를 그냥 부르려고 할 때 나는 컴파일 에러 — 자바를 처음 배울 때 가장 많이 만나는 에러다.
쉽게 말하면가게가 문도 열기 전에 "3번 테이블 손님 주문 받아 와"라고 시킨 상황입니다. 3번 테이블 손님(객체)이 아직 없으니까요. 그러니 손님을 앉히거나(객체 생성), 아니면 손님과 무관한 일로 바꾸거나(static으로 선언) 해야 합니다.
두 가지 해결책 — 둘 다 정답이다
객체를 만들어 부른다 — D2_MethodTest t = new D2_MethodTest(); t.test02(); (3일차 수업에서 쓴 방식)
그 메서드도 static으로 선언한다 — 상태 없이 계산만 하는 메서드라면 이쪽이 더 간결합니다. (1일차 testMethod()가 이 방식)
같은 이유로 static 메서드 안에서는 인스턴스 필드와 this도 쓸 수 없습니다. 반대 방향(인스턴스 메서드에서 static 부르기)은 아무 문제 없습니다 — static은 이미 메모리에 있으니까요. → 🧱 05. static vs non-static · 📅 3일차 수업 진도
핵심 정리
원인 = static인 자리(main 등)에서 인스턴스 멤버를 직접 호출.
해결 = 객체 생성 또는 그 멤버도 static으로.
static → 인스턴스 (X) / 인스턴스 → static (O).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
main에서 다른 메서드를 바로 못 부르는 이유를 설명해보세요.
main이 static이라 객체 없이 실행되기 때문입니다. 인스턴스 메서드는 "어느 객체의" 메서드인지가 정해져야 하는데, static 문맥에는 그 객체가 없습니다. 그래서 new로 객체를 만들어 부르거나, 그 메서드도 static으로 만들어야 합니다.
이 에러를 만났을 때 static을 붙이는 해결이 항상 옳을까?
아닙니다. 편하다고 다 static으로 만들면 객체지향이 아니라 절차적 코드가 되고, 상태를 다루기 어려워집니다. 상태가 필요 없는 기능만 static으로 두고, 나머지는 객체를 만들어 쓰는 것이 맞습니다.
💼 실무·코딩테스트에서는자바를 처음 배울 때 가장 많이 만나는 에러입니다. 이 에러를 정확히 이해하면 static과 인스턴스의 차이가 몸에 붙습니다 — 그래서 오히려 좋은 학습 기회입니다.
21
cannot find symbol — "그런 이름을 못 찾겠다"
오타스코프import컴파일 에러
한 줄 요약변수·메서드·클래스 이름을 찾지 못했다는 뜻으로, 원인은 거의 항상 오타 · 선언하지 않음 · 블록 밖에서 사용 · import 누락 넷 중 하나다.
쉽게 말하면출석부에 없는 이름을 부른 것입니다. 이름을 잘못 불렀거나(오타), 아직 등록을 안 했거나(선언 안 함), 다른 반 학생을 우리 반에서 부른 것(스코프 밖)이죠.
메시지의 symbol: 줄부터 보세요
에러에 symbol: variable sum, location: class Test처럼 무엇을, 어디서 찾았는지가 같이 나옵니다. 그다음 확인 순서는 —
철자와 대소문자. 자바는 Number와 number를 다른 이름으로 봅니다.
선언 위치. for문이나 if 블록 안에서 선언한 변수는 그 { }를 벗어나면 존재하지 않습니다(Stack에서 사라짐). 밖에서도 써야 하면 블록 밖에서 선언하세요.
메서드 이름. getSiz()처럼 이름 자체가 틀리면 "cannot find symbol"입니다. 반면 이름은 맞고 매개변수만 다르면(getSize(int pw)만 있는데 getSize()로 호출) cannot find symbol이 아니라 "method getSize in class ... cannot be applied to given types"로 나옵니다 — 메시지가 다르니 구분해서 읽으세요.
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
이 에러가 알려 주는 것을 세 가지 가능성으로 나눠 보세요.
① 오타(대소문자 포함) ② 선언을 안 했거나 스코프 밖 ③ import 누락. 자바는 대소문자를 엄격히 구분하므로 Integer.parseint처럼 한 글자만 틀려도 이 에러가 납니다.
메시지의 'symbol'과 'location'을 어떻게 읽어야 하나?
symbol은 못 찾은 이름, location은 어디서 찾으려 했는지입니다. symbol: method parseint(String), location: class Integer라면 Integer에 그런 메서드가 없다는 뜻이라 이름을 확인하면 됩니다.
💼 실무·코딩테스트에서는컴파일 에러는 실행 전에 잡히므로 사실 고마운 에러입니다. 메시지를 끝까지 읽는 습관만 들이면 대부분 즉시 해결되는데, 초보자일수록 빨간 글씨를 보고 당황해 안 읽는 경향이 있습니다.
22
incompatible types: possible lossy conversion — "값이 깨질 수 있다"
형 변환강제 형변환(축소 변환)자동 승격
한 줄 요약큰 상자의 값을 작은 상자에 넣으려 할 때 나는 컴파일 에러다. 정말 괜찮다면 (byte)·(int)처럼 강제 형 변환을 직접 써서 "손실을 감수하겠다"고 표시해야 통과한다.
쉽게 말하면2L 물을 500mL 컵에 부으려는 상황이에요. 자바는 "넘칠 텐데 진짜 할 거야?"라고 묻고 멈춥니다. (int)를 붙이는 건 "응, 넘치는 건 버려도 돼"라고 서명하는 것입니다.
세 가지 대표 상황
큰 타입 → 작은 타입 — int i = 100000; byte b = i; ❌ → byte b = (byte) i; (값은 깨집니다)
실수 → 정수 — double d = 15.7; int i = d; ❌ → int i = (int) d; (소수점 아래는 반올림이 아니라 잘림 → 15)
연산 결과의 자동 승격 — byte b1 = 10, b2 = 20; byte b3 = b1 + b2; ❌. byte끼리 더해도 결과는 int로 승격되기 때문입니다. byte b3 = (byte)(b1 + b2);로 해결. 반면 byte b4 = 10 + 20;은 리터럴이라 컴파일 시점에 30으로 계산돼 통과합니다 — 1일차 VariableTest가 정확히 이 비교였습니다.
반대 방향(작은 → 큰)은 손실이 없으므로 자동으로 변환됩니다. 그리고 float f = 15.77;이 에러인 것도 같은 이유예요 — 실수 리터럴의 기본이 double이라 f를 붙여야 합니다. → ☕ 06. 형 변환(캐스팅) 총정리
핵심 정리
큰 타입 → 작은 타입은 강제 형 변환을 직접 써야 한다.
실수 → 정수는 반올림이 아니라 소수점 아래를 버린다.
byte + byte의 결과는 int로 승격된다.
정수 리터럴의 기본은 int, 실수 리터럴의 기본은 double(→ L, f 접미사).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
이 에러가 '경고'가 아니라 '에러'인 이유는?
값이 조용히 깨지는 것이 더 위험하기 때문입니다. long을 int에 넣으면 상위 비트가 잘려 완전히 다른 값이 되는데, 이걸 경고로만 두면 실행 후에야 이상함을 발견하게 됩니다. 그래서 컴파일러가 의도를 명시하라고 강제합니다.
(int)를 붙여 에러를 없애는 것이 항상 옳은 해결인가?
아닙니다. 값이 실제로 int 범위를 넘는다면 캐스팅해도 값은 깨집니다 — 에러만 사라질 뿐이죠. 왜 long이 필요했는지를 먼저 확인하고, 필요하면 받는 쪽을 long으로 바꾸는 것이 맞을 수 있습니다.
💼 실무·코딩테스트에서는10회차 코딩테스트의 "자연수 뒤집어 배열로 만들기"가 정확히 이 에러였습니다 — long 나머지를 int[]에 담으려다 났죠. 문제에서 큰 수를 준다면 long을 쓰라는 신호이고, 그러면 이 캐스팅이 따라옵니다.
23
ClassCastException — 참조타입을 잘못 형 변환했다
다운캐스팅instanceof다형성
한 줄 요약부모 타입으로 담아 둔 객체를 실제와 다른 자식 타입으로 강제 변환했을 때 나는 실행 중 에러다 — 컴파일러는 "가능성이 있다"고 보고 통과시키지만, 실행 시점의 진짜 정체가 다르면 터진다.
쉽게 말하면"동물"이라고 적힌 상자를 열면서 "고양이겠지" 하고 고양이 사료를 준 상황입니다. 안에 강아지가 들어 있으면 사고가 나죠. 그래서 열기 전에 "너 고양이 맞아?"라고 먼저 물어보는 것이 instanceof입니다.
안전하게 다운캐스팅하는 법
업캐스팅(자식 → 부모)은 항상 안전해서 자동으로 됩니다. 위험한 건 다운캐스팅(부모 → 자식)이라, 하기 전에 if (obj instanceof Cat) { Cat c = (Cat) obj; ... }로 확인합니다. 요즘 문법에서는 if (obj instanceof Cat c)처럼 확인과 형 변환을 한 번에 할 수도 있습니다. → 🧱 20. 참조타입 형 변환 · 🧱 18. 상속과 다형성
핵심 정리
업캐스팅(자식→부모)은 자동·안전, 다운캐스팅(부모→자식)이 위험하다.
다운캐스팅 전에는 instanceof로 확인한다.
컴파일은 통과하고 실행 중에 터진다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
ClassCastException이 컴파일 때 안 잡히는 이유는?
변수의 타입만으로는 실제 객체가 무엇인지 알 수 없기 때문입니다. Object o에 무엇이 들었는지는 실행해 봐야 알죠. 그래서 컴파일러는 "가능성은 있다"고 통과시키고 실행 시점에 확인합니다.
이 예외를 예방하는 방법 두 가지는?
① instanceof로 확인한 뒤 캐스팅 ② 제네릭을 써서 애초에 타입을 고정하기. 제네릭이 등장한 이유 자체가 이 예외를 컴파일 시점으로 옮기기 위해서였습니다.
💼 실무·코딩테스트에서는제네릭 이전 자바에서 가장 흔한 런타임 예외였습니다. 지금도 Object로 받는 레거시 코드나 역직렬화에서 만나게 되죠. 자바 16+의 if (o instanceof Dog d) 패턴 매칭을 알아 두면 코드가 깔끔해집니다.
한 줄 요약nextInt()는 숫자만 가져가고 엔터(개행)를 버퍼에 남기기 때문에, 바로 뒤의 nextLine()이 그 개행을 읽고 빈 문자열을 반환하며 입력을 건너뛴 것처럼 보인다. 숫자를 기대한 자리에 글자가 들어오면 InputMismatchException이 난다.
쉽게 말하면키보드로 친 입력은 컨베이어 벨트에 차례로 올라와 있습니다. 25⏎를 치면 벨트에는 2, 5, ⏎(엔터)가 놓이죠. nextInt()는 숫자 25만 집어 가고 엔터는 벨트에 그대로 남겨 둡니다. 이어서 nextLine()이 "엔터가 나올 때까지 가져가기"를 하면, 벨트 맨 앞이 바로 그 엔터라서 빈 줄만 받아 가고 끝나 버리죠 — 그래서 아무것도 못 받은 것처럼 보입니다.
가장 깔끔한 해결 — 처음부터 한 줄로 읽기
int n = Integer.parseInt(sc.nextLine());처럼 항상 nextLine()으로 한 줄을 통째로 읽고 숫자로 바꾸면 개행이 남는 문제가 아예 생기지 않습니다. 2일차 실습에서 D1은 nextInt()를, D2는 Integer.parseInt(sc.nextLine())을 썼는데, 뒤쪽이 실무에서 권장되는 방식입니다. nextInt()를 계속 쓰겠다면 그 뒤에 sc.nextLine();을 버리는 용도로 한 번 더 호출하면 됩니다.
InputMismatchException은 다른 문제다
이쪽은 타입이 안 맞는 경우입니다 — nextInt()를 기다리는데 사용자가 abc를 친 상황이죠. try-catch로 감싸 다시 입력받게 하거나, nextLine()으로 읽은 뒤 Integer.parseInt()를 try 안에서 처리하는 방식이 일반적입니다(이 경우 예외 이름은 NumberFormatException이 됩니다). → ☕ 11. 키보드 입력 — Scanner · 🛠️ 05. 예외 처리
핵심 정리
nextInt()는 개행을 버퍼에 남긴다 → 뒤의 nextLine()이 건너뛴 것처럼 보인다.
권장: Integer.parseInt(sc.nextLine())로 통일한다.
타입 불일치는 InputMismatchException(Scanner) / NumberFormatException(parseInt).
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
nextInt() 뒤 nextLine()이 빈 문자열을 반환하는 과정을 순서대로 설명해보세요.
① 사용자가 5를 치고 엔터를 누름 → ② nextInt()가 5만 읽고 엔터는 버퍼에 남김 → ③ nextLine()이 그 남은 엔터까지 읽고 끝 → ④ 결과가 빈 문자열. 사용자는 입력할 기회조차 못 얻습니다.
InputMismatchException은 언제 나나?
기대한 타입과 다른 것이 들어왔을 때입니다 — nextInt()에 abc를 넣으면 나죠. 이때 잘못된 입력이 버퍼에 그대로 남아 다시 읽으려 하면 무한 반복이 될 수 있어서, 버퍼를 비우는 처리가 필요합니다.
💼 실무·코딩테스트에서는Scanner 함정은 자바 입문자가 반드시 한 번은 겪는 통과의례입니다. 원리를 이해하면 버퍼라는 개념이 잡히고, 이게 나중에 IO·네트워크 프로그래밍에서 그대로 쓰입니다. 입력을 직접 읽어야 하는 백준식 문제에서는 아예 BufferedReader를 쓰는 편이 낫습니다(프로그래머스처럼 함수의 매개변수로 값을 받는 문제는 입력 코드 자체가 필요 없습니다).
E · 🗄️ SQL 개념 — 데이터베이스에서 헷갈리는 것
25
SQL의 NULL — "0"도 "빈칸"도 아닌 "모름"
NULLIS NULLIFNULL3값 논리
한 줄 요약NULL은 값이 아니라 "값을 알 수 없다"는 상태다. 그래서 NULL과의 비교는 참도 거짓도 아닌 UNKNOWN이 되고, = NULL은 영원히 참이 되지 않는다.
쉽게 말하면"저 사람 나이가 몇이야?"에 "몰라"라고 답한 상황입니다. 여기서 "그럼 20살이 아닌 건 확실해?"라고 물으면 "그것도 몰라"가 맞는 답이죠. SQL도 똑같이 대답합니다 — 그래서 참이 아니니 그 행은 결과에서 빠집니다.
왜 = NULL이 아니라 IS NULL인가
SQL은 참·거짓에 UNKNOWN을 더한 3값 논리를 씁니다. NULL = NULL조차 UNKNOWN이라 참이 아닙니다. 그래서 NULL을 찾는 전용 문법 IS NULL / IS NOT NULL이 따로 있습니다. → 🗄️ 05. WHERE
가장 자주 당하는 함정 — NOT IN
NOT IN은 목록의 모든 값과 달라야 참인데, 목록에 NULL이 하나라도 있으면 그 비교가 UNKNOWN이 되어 영원히 참이 될 수 없습니다. 결과가 에러 없이 조용히 0행이 되므로 알아채기 어렵습니다. 서브쿼리 안에서 WHERE 컬럼 IS NOT NULL로 NULL을 걸러 내거나, NULL에 영향받지 않는 NOT EXISTS로 쓰는 것이 안전합니다. (IFNULL(컬럼, 0)으로 바꾸는 방법은 0이 실제 값과 겹칠 수 있어 권하지 않습니다.) → 🗄️ 14. NULL 함정
집계 함수와 NULL
COUNT(*)를 뺀 모든 집계 함수는 NULL을 아예 빼고 계산합니다. 사원 14명 중 커미션이 있는 사람이 4명이면 AVG(comm)의 분모는 14가 아니라 4입니다. 전체 기준 평균이 필요하면 AVG(IFNULL(comm,0))으로 써야 합니다. → 🗄️ 17. 집계 함수
NULL을 대체하는 함수들
IFNULL(값, 대체값)은 MySQL·MariaDB, COALESCE(값, 대체값, ...)는 표준 SQL이라 어디서나 동작합니다. NVL()은 Oracle 함수인데 MariaDB에서는 호환용으로 동작합니다(직접 확인). 다만 MySQL에는 없으므로 IFNULL·COALESCE를 쓰는 편이 안전합니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
NULL을 '0'이나 '빈 문자열'로 대신 쓰면 무엇이 문제가 되나?
'값이 0이다'와 '값을 모른다'가 구분되지 않기 때문입니다. 시험 점수가 0점인 것과 아직 안 본 것은 완전히 다르죠. 평균을 낼 때 0점은 분모에 들어가야 하지만 미응시는 빠져야 합니다 — NULL이 그 구분을 가능하게 합니다.
IN은 괜찮고 NOT IN만 위험한 이유를 논리로 설명해보세요.
IN은 OR로 풀리고 참 OR UNKNOWN = 참이라 다른 값과 맞으면 통과합니다. NOT IN은 AND로 풀리고 참 AND UNKNOWN = UNKNOWN이라 절대 참이 못 됩니다. 같은 NULL인데 연산자에 따라 결과가 갈리는 것이죠.
💼 실무·코딩테스트에서는"NOT IN과 NOT EXISTS의 차이"는 SQL 면접 단골이고, 기대하는 답이 정확히 이 NULL 이야기입니다. 실무 데이터에는 NULL이 반드시 섞여 있으므로, 이 성질을 모르면 조용히 틀린 리포트를 만들게 됩니다.
26
WHERE vs HAVING — 조건을 거는 두 자리
WHEREHAVING실행순서
한 줄 요약WHERE는 묶기 전 개별 행을, HAVING은 묶은 뒤 그룹을 거른다. 실행 순서를 알면 왜 집계 함수를 WHERE에 못 쓰는지 자연히 이해된다.
쉽게 말하면공장의 검사 지점 두 곳이라고 생각하면 쉬워요. WHERE는 부품이 상자에 담기기 전 하나씩 보는 검사이고, HAVING은 상자에 담고 무게를 잰 뒤 상자째 걸러내는 검사입니다. "무게 5kg 이상인 상자만"은 담아 봐야 알 수 있으니 두 번째 지점에서만 가능하죠.
실행 순서가 모든 것을 설명한다
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY 순서로 실행됩니다. WHERE가 GROUP BY보다 먼저이므로 그 시점엔 아직 그룹이 없고, 따라서 SUM()·COUNT() 같은 집계값도 존재하지 않습니다. → 🗄️ 19. HAVING vs WHERE
별칭을 어디서 쓸 수 있는가
SELECT가 WHERE보다 늦게 실행되므로, SELECT sal*12 AS 연봉으로 만든 별칭을 WHERE 연봉 > 1000처럼 쓸 수 없습니다. 반면 ORDER BY는 가장 마지막이라 별칭을 쓸 수 있습니다. 다만 이것은 표준 SQL 기준이고, MySQL·MariaDB는 예외적으로 GROUP BY·HAVING에서도 SELECT 별칭을 허용합니다 — 🧩 ct-08의 GROUP BY TRUNCATE(PRICE, -4)를 GROUP BY PRICE_GROUP으로 써도 MySQL에서는 동작합니다. WHERE에서는 MySQL·MariaDB도 별칭을 쓸 수 없습니다.
어느 쪽에 써야 빠른가
집계와 무관한 조건은 WHERE에 쓰는 편이 빠릅니다 — 묶기 전에 미리 걸러내면 묶을 대상 자체가 줄어들기 때문입니다. HAVING은 이미 다 묶은 뒤에 버리는 것이라 낭비가 생깁니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
같은 조건을 WHERE와 HAVING 둘 다에 쓸 수 있을 때 어느 쪽이 나은가?
WHERE입니다. 묶기 전에 걸러내면 묶을 대상 자체가 줄어 더 빠릅니다. HAVING은 이미 다 계산한 뒤 버리는 것이라 낭비가 생기죠. 집계 함수가 꼭 필요할 때만 HAVING이 원칙입니다.
SELECT에서 만든 별칭을 WHERE에서는 못 쓰고 ORDER BY에서는 쓸 수 있는 이유는?
실행 순서 때문입니다. WHERE는 SELECT보다 먼저 실행돼 그 시점엔 별칭이 없고, ORDER BY는 나중이라 이미 만들어져 있습니다. 표준 SQL은 이 순서를 그대로 규칙으로 삼습니다. 단, MySQL·MariaDB는 GROUP BY·HAVING에서 별칭을 예외적으로 허용하니, DB마다 다를 수 있다는 것도 함께 기억하세요.
💼 실무·코딩테스트에서는실행 순서를 외워 두면 SQL 에러의 절반이 저절로 설명됩니다. 면접에서도 "SQL 실행 순서를 말해 보세요"가 자주 나오고, 실무 쿼리 튜닝의 출발점이기도 합니다.
27
JOIN — 나눠 저장한 표를 다시 잇기
JOININNEROUTER카티시안 곱
한 줄 요약정규화로 중복 없이 나눠 저장한 표들을 공통 값으로 다시 붙이는 것이 JOIN이다. INNER는 양쪽에 짝이 있는 행만, OUTER는 한쪽을 전부 남긴다.
쉽게 말하면사원 표에는 부서 번호만 있고 부서 이름은 부서 표에 있어요. 왜 이렇게 나눠 놨을까요? 부서 이름을 사원 표에 그대로 적어 두면 부서명이 바뀔 때 사원 수만큼 고쳐야 하니까요. 나눠 두면 한 곳만 고치면 됩니다. 대신 볼 때는 다시 붙여야 하고, 그게 JOIN입니다.
INNER JOIN — 짝이 없으면 버린다
A JOIN B ON A.컬럼 = B.컬럼은 양쪽 모두에 있는 행만 남깁니다. 그래서 사원이 0명인 부서나 관리자가 없는 사장(mgr이 NULL)은 결과에서 조용히 사라집니다. 행이 갑자기 줄었다면 이걸 먼저 의심하세요. → 🗄️ 24. JOIN
OUTER JOIN과 COUNT(*)의 함정
LEFT/RIGHT OUTER JOIN은 기준 쪽을 전부 남기고 짝이 없으면 NULL로 채웁니다. 그런데 NULL로 채워진 줄도 한 줄은 한 줄이라, COUNT(*)는 사원 0명인 부서를 1로 셉니다. COUNT(e.ename)처럼 짝 쪽 컬럼을 세야 0이 나옵니다. → 🗄️ 33. OUTER JOIN
세 가지 변형 — 다중 · 셀프 · 비등가
다중 조인은 표 세 개 이상을 줄줄이 잇는 것, 셀프 조인은 같은 표를 다른 별칭으로 두 번 불러 사원과 관리자를 나란히 놓는 것, 비등가 조인은 ON e.sal BETWEEN s.losal AND s.hisal처럼 =가 아닌 범위로 잇는 것입니다. → 🗄️ 32. JOIN 심화
카티시안 곱 — 가장 흔한 사고
ON 조건을 빠뜨리면 모든 행이 서로 곱해집니다. 사원 14명 × 부서 4개 = 56행. 옛날식 쉼표 조인(FROM emp, dept WHERE ...)이 위험한 이유이고, JOIN ... ON 문법을 쓰면 조건 자리가 눈에 띄어 빠뜨리기 어렵습니다. 다만 MySQL·MariaDB는 ON 없이 JOIN만 써도 에러 없이 카티시안 곱으로 실행하므로(CROSS JOIN처럼 동작), 결과 행 수를 꼭 확인하세요.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
JOIN이 필요해지는 근본 원인은 무엇인가?
정규화입니다. 중복을 없애려고 데이터를 여러 표로 나눴기 때문에, 볼 때는 다시 붙여야 하죠. 만약 모든 걸 한 표에 넣으면 조인은 필요 없지만 부서명이 바뀔 때 수천 행을 고쳐야 합니다. 조인은 정규화의 대가입니다.
행이 갑자기 줄었다면 무엇을 먼저 의심해야 하나?
INNER JOIN이 짝 없는 행을 버렸을 가능성입니다. 특히 조인 컬럼에 NULL이 있으면 절대 매칭되지 않아 조용히 사라집니다. LEFT JOIN으로 바꿔 보면 몇 행이 빠졌는지 바로 드러납니다.
💼 실무·코딩테스트에서는실무 SQL의 대부분이 JOIN이고, 조인 후 행 수가 예상과 다른 것이 가장 흔한 버그입니다. 조인 전후로 COUNT(*)를 찍어 보는 습관이 좋고, 1:N 조인에서 행이 늘어나는 것도 반드시 의식해야 합니다.
28
GROUP BY vs 윈도우 함수 — 접을 것인가 붙일 것인가
GROUP BYOVERPARTITION BY
한 줄 요약GROUP BY는 여러 행을 한 줄로 접어 개별 정보를 잃고, 윈도우 함수(OVER)는 행을 그대로 두고 칸을 늘려 집계값을 옆에 붙인다.
쉽게 말하면성적표를 떠올리면 정확합니다. GROUP BY는 "반 평균 78점" 한 줄만 남기고 학생 이름은 지워 버립니다. 윈도우 함수는 "홍길동 85점 (반 평균 78)"처럼 내 줄은 그대로 두고 옆에 평균을 적어 줍니다. 둘 다 필요한 상황이 다릅니다.
PARTITION BY = 행을 합치지 않는 GROUP BY
AVG(sal) OVER(PARTITION BY deptno)는 부서별로 평균을 내되 사원 14명을 그대로 남깁니다. 같은 부서 사원들 옆에는 같은 값이 반복해서 붙습니다. → 🗄️ 28. 윈도우 함수 기초
순위 함수 세 가지의 동점 처리
키 182인 사람이 둘일 때 — ROW_NUMBER는 2·3으로 억지로 가르고, RANK는 둘 다 2등 뒤 3을 건너뛰어 4, DENSE_RANK는 둘 다 2등 뒤 3으로 이어집니다. "6~10등만 보기" 같은 페이지 나누기에는 중복이 없는 ROW_NUMBER를 씁니다. → 🗄️ 29. 순위 함수
윈도우 함수는 WHERE에서 쓸 수 없다
실행 순서상 WHERE가 윈도우 함수보다 먼저 동작하기 때문입니다. 순위로 조건을 걸려면 인라인 뷰(FROM 안에 넣은 서브쿼리)로 한 겹 감싼 뒤 바깥에서 WHERE RN BETWEEN 6 AND 10으로 걸러야 합니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
'전체 대비 내 비중'을 GROUP BY만으로 구하려면 어떻게 해야 하나?
전체 합계를 구하는 서브쿼리를 따로 붙여야 합니다 — SELECT sal / (SELECT SUM(sal) FROM emp) ...처럼요. 테이블을 두 번 훑는 셈이죠. 윈도우 함수를 쓰면 sal / SUM(sal) OVER()로 한 번에 끝납니다.
윈도우 함수를 WHERE에서 못 쓰는 것이 왜 자연스러운가?
윈도우 함수는 결과 집합이 정해진 뒤 계산되기 때문입니다. WHERE가 실행될 때는 아직 어떤 행들이 남을지 확정되지 않아 순위를 매길 수 없죠. 그래서 인라인 뷰로 한 겹 감싸 계산을 끝낸 뒤 걸러야 합니다.
💼 실무·코딩테스트에서는윈도우 함수는 데이터 분석 직무의 핵심 도구입니다. 전월 대비 증감, 그룹 내 순위, 누적 합계가 전부 여기서 나오죠. 코딩테스트 SQL 고난도 문제도 대부분 윈도우 함수를 요구합니다.
29
제약조건 — 데이터베이스가 대신 지켜 주는 규칙
PRIMARY KEYFOREIGN KEYNOT NULLCHECK
한 줄 요약제약조건은 잘못된 데이터가 애초에 들어오지 못하게 막는 규칙이다. 프로그램에서 검사하는 것과 달리, 어느 경로로 들어오든 반드시 지켜진다.
쉽게 말하면출입구마다 경비를 세우는 대신 건물 입구 한 곳에만 세우는 것과 같아요. 프로그램이 여러 개고 사람이 직접 SQL을 칠 수도 있는데, 규칙을 데이터베이스에 걸어 두면 모든 경로에서 자동으로 지켜집니다. 자바에서 private + setter로 값을 검증하던 캡슐화와 목적이 같습니다.
네 가지 기본 제약조건
NOT NULL(반드시 값이 있어야 함) · PRIMARY KEY(중복 불가 + NULL 불가, 행을 구분하는 유일한 값) · FOREIGN KEY(다른 표의 PK를 가리킴) · CHECK(값의 범위 강제). UNIQUE는 중복만 막고 NULL은 허용한다는 점에서 PK와 다릅니다. → 🗄️ 10. CREATE TABLE
순서가 중요하다
참조되는 쪽(부모)에 PK가 먼저 있어야 자식이 FK로 참조할 수 있습니다. 테이블 생성도 부모 먼저, 데이터 입력도 부모 먼저입니다. 순서를 어기면 errno 150(생성 시)이나 ERROR 1452(입력 시)가 납니다. → 🗄️ 34. 제약조건 심화
나중에 붙이기와 잠시 끄기
ALTER TABLE ... ADD CONSTRAINT 이름 ...으로 뒤늦게 붙일 수 있고, 이름을 붙여 두면 나중에 지우기 쉽습니다. SET foreign_key_checks = 0으로 검사를 잠시 끌 수도 있지만, 그동안 들어간 데이터는 참조 대상이 없는 채로 남아 무결성이 깨집니다 — 작업 후 반드시 다시 켜고 확인해야 합니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
제약조건을 애플리케이션에서만 검사하면 무엇이 뚫리나?
다른 경로로 들어오는 모든 데이터입니다. 배치 작업, 다른 서비스, 운영자가 직접 친 SQL, 데이터 마이그레이션 스크립트… 코드에만 검사를 두면 이 중 하나만 빠뜨려도 잘못된 데이터가 들어옵니다. DB 제약은 마지막 방어선입니다.
PRIMARY KEY와 UNIQUE의 차이를 두 가지 말해보세요.
① PK는 NULL을 허용하지 않지만 UNIQUE는 허용합니다. ② PK는 테이블당 하나지만 UNIQUE는 여러 개 걸 수 있습니다. 또 PK는 클러스터형 인덱스가 되어 저장 순서에도 영향을 줍니다.
💼 실무·코딩테스트에서는실무 스키마 설계에서 제약조건을 어디까지 걸지는 팀마다 논쟁거리입니다. "성능 때문에 FK를 안 건다"는 팀도 있죠. 다만 왜 그런 선택을 했는지 설명할 수 있어야 하고, 안 걸었다면 정합성을 어떻게 보장할지 대안이 있어야 합니다.
30
트랜잭션 — 전부 되거나, 전부 안 되거나
트랜잭션COMMITROLLBACKACID
한 줄 요약여러 개의 DML을 하나의 작업 단위로 묶어, 전부 성공하면 확정(COMMIT)하고 하나라도 실패하면 전부 되돌리는(ROLLBACK) 장치다.
쉽게 말하면계좌 이체가 교과서적인 예입니다. "A에서 100만원 빼기"와 "B에 100만원 넣기"는 반드시 둘 다 되거나 둘 다 안 돼야 합니다. 첫 번째만 성공하고 정전이 나면 돈이 공중분해되죠. 트랜잭션은 이 둘을 하나로 묶어 중간 상태가 남지 않도록 보장합니다.
COMMIT과 ROLLBACK
START TRANSACTION으로 시작해 COMMIT이면 확정, ROLLBACK이면 시작 시점으로 되돌립니다. MySQL은 기본이 자동 커밋이라 문장 하나하나가 즉시 확정되므로, 묶으려면 명시적으로 트랜잭션을 시작해야 합니다.
DDL은 되돌릴 수 없다
MySQL·MariaDB·Oracle에서는 CREATE·ALTER·DROP·TRUNCATE 같은 DDL이 자동 커밋되어 ROLLBACK이 통하지 않습니다(PostgreSQL·SQL Server는 트랜잭션 안의 DDL도 롤백할 수 있습니다). 그래서 DELETE(DML)는 되돌릴 수 있어도 TRUNCATE(DDL)는 되돌릴 수 없습니다. → 🗄️ 22. DELETE · TRUNCATE · DROP
ACID 네 글자
Atomicity(원자성 — 전부 아니면 전무) · Consistency(일관성 — 제약조건이 항상 지켜짐) · Isolation(격리성 — 동시 작업이 서로 방해하지 않음) · Durability(지속성 — 커밋된 것은 장애가 나도 남음). 면접에서 자주 묻는 네 글자입니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
트랜잭션이 필요한 상황을 계좌 이체 말고 다른 예로 들어보세요.
주문 처리가 좋은 예입니다 — 주문 생성, 재고 차감, 결제 기록이 전부 되거나 전부 안 되어야 합니다. 재고만 줄고 주문이 안 만들어지면 물건이 증발하죠. 여러 표를 함께 바꾸는 모든 작업이 트랜잭션 대상입니다.
DDL은 왜 롤백이 안 되나?
테이블 구조 자체가 바뀌기 때문입니다. 데이터는 되돌릴 로그를 남길 수 있지만, 구조 변경은 되돌리는 비용이 너무 크고 복잡합니다. 그래서 MySQL·MariaDB·Oracle은 DDL을 자동 커밋으로 처리하고(PostgreSQL·SQL Server는 DDL도 롤백 가능), 그만큼 신중하게 실행해야 합니다.
💼 실무·코딩테스트에서는ACID는 백엔드 면접의 필수 주제이고, 특히 격리 수준(Isolation Level)과 그에 따른 이상 현상(Dirty Read, Phantom Read)까지 물어보는 경우가 많습니다. 실무에서는 스프링의 @Transactional로 다루게 됩니다.
31
인덱스 — 책의 목차와 찾아보기
INDEX클러스터형보조 인덱스
한 줄 요약인덱스는 찾기를 빠르게 하는 색인이다. 클러스터형은 데이터를 그 순서로 실제 정렬해 두는 것이라 테이블당 하나뿐이고, 보조 인덱스는 위치만 적어 두는 것이라 여러 개 만들 수 있다.
쉽게 말하면목차는 책 내용 자체가 그 순서로 배치돼 있어서 하나뿐입니다(클러스터형). 찾아보기는 "이 단어는 몇 쪽"만 적어 둔 것이라 여러 개 만들 수 있죠(보조). 둘 다 찾기는 빨라지지만, 책 내용이 바뀔 때마다 목차와 색인도 고쳐야 하니 수정은 느려집니다.
언제 자동으로 생기나
PRIMARY KEY를 만들면 클러스터형 인덱스가, UNIQUE를 만들면 보조 인덱스가 자동으로 생깁니다. PK가 없고 UNIQUE NOT NULL인 컬럼이 있으면 그것이 클러스터형이 되기도 합니다. SHOW INDEX FROM 테이블;로 확인합니다. → 🗄️ 36. 인덱스
공짜가 아니다
인덱스는 검색을 빠르게 하지만 INSERT·UPDATE·DELETE를 느리게 합니다. 데이터가 바뀔 때마다 색인도 함께 고쳐야 하기 때문입니다. 그래서 자주 검색하는 컬럼에만 거는 것이 원칙입니다.
만들어도 안 쓰이는 경우
LIKE '%수'처럼 앞이 열린 패턴이나 WHERE YEAR(hiredate) = 2020처럼 컬럼을 함수로 감싼 조건에서는 인덱스를 쓸 수 없습니다. 인덱스는 값이 정렬된 순서에 의존하는데, 앞을 모르거나 값을 가공하면 그 순서를 활용할 수 없기 때문입니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
인덱스를 '무조건 많이 걸면 좋다'가 틀린 이유는?
쓰기가 느려지고 공간을 더 쓰기 때문입니다. 데이터가 바뀔 때마다 걸린 인덱스를 전부 갱신해야 하죠. 조회가 거의 없는 컬럼에 인덱스를 걸면 비용만 내고 이득은 없는 상태가 됩니다.
복합 인덱스 (A, B)가 B 단독 검색에 안 쓰이는 이유를 비유로 설명해보세요.
전화번호부가 성→이름 순으로 정렬된 것과 같습니다. 성을 알면 어디를 펼칠지 알지만, 이름만 알고는 처음부터 다 뒤져야 하죠. 인덱스도 앞 컬럼부터 정렬되어 있어 앞을 건너뛸 수 없습니다(왼쪽 접두사 규칙).
💼 실무·코딩테스트에서는"느린 쿼리를 어떻게 개선하나요"는 백엔드 면접 단골이고, 답의 중심이 인덱스입니다. EXPLAIN으로 실행 계획을 확인하는 것부터 시작한다고 답하면 실무 감각이 있다는 인상을 줍니다.
32
SQL 에러 메시지 사전 — 자주 만나는 여덟 가지
ERROR 1064ERROR 1055ERROR 1452에러사전
한 줄 요약SQL 에러는 번호가 붙어 있어 번호만 봐도 원인을 좁힐 수 있다. 수업과 실습에서 실제로 마주친 것들을 모았다.
쉽게 말하면에러 메시지는 겁주는 말이 아니라 힌트입니다. 특히 SQL은 번호가 정해져 있어서, 1064면 문법, 1055면 GROUP BY, 1452면 외래키처럼 번호와 원인이 거의 1:1로 대응합니다. 몇 개만 외워 두면 디버깅이 훨씬 빨라집니다.
ERROR 1064 — 문법 오류(가장 흔함)
괄호·쉼표·따옴표가 빠졌거나 오타가 있을 때입니다. 실제로 겪은 예: SELECT deptno AVG(sal)(쉼표 누락), BETWEEN 6 A.ND 10(AND 오타), FIRST_VALUE(height,1)(인자 개수 초과), FROM (SELECT ...) 뒤 별칭 누락. 메시지의 near '...' 뒤에 나오는 부분의 바로 앞을 보면 대개 원인이 있습니다.
ERROR 1055 / 1140 — GROUP BY 관련
1055 '...' isn't in GROUP BY는 집계 대상이 아닌 컬럼을 SELECT나 HAVING에 쓴 경우, 1140 Mixing of GROUP columns with no GROUP columns는 GROUP BY 없이 일반 컬럼과 집계 함수를 섞은 경우입니다. MariaDB 기본 설정에서는 통과하지만 MySQL 5.7 이상에서는 에러라 특히 조심해야 합니다.
ERROR 1452 / 1062 — 제약조건 위반
1452 Cannot add or update a child row는 없는 부모를 참조하는 외래키 위반, 1062 Duplicate entry는 기본키·UNIQUE 중복입니다. 1062는 ON DUPLICATE KEY UPDATE로 "있으면 수정"으로 처리할 수 있습니다. 4025 CONSTRAINT ... failed는 CHECK 위반입니다.
ERROR 1146 / 1093 — 대상 문제
1146 Table ... doesn't exist는 테이블 이름 오타이거나, CTE(WITH 이름 AS (SELECT ...)로 만든 임시 결과) 이름을 다음 문장에서 다시 부른 경우입니다(CTE는 한 문장 안에서만 유효). 1093은 수정하려는 테이블을 서브쿼리에서 직접 읽어서 나며, FROM (SELECT ...) AS temp로 한 겹 감싸면 우회됩니다.
에러가 안 나는 것이 더 위험하다
실습에서 찾은 것 중 가장 위험했던 건 에러가 나지 않는 실수였습니다. ORDER BY '컬럼명'(정렬이 안 됨), LIMIT3(공백 누락 — 테이블 별칭으로 읽혀 제한이 안 걸림), NOT IN에 NULL(조용히 0행), sal - 400(더하라는데 뺌). 결과 행 수가 예상과 다르면 일단 의심하는 습관이 필요합니다.
🤔 스스로 확인
답을 펼치기 전에 머릿속으로 먼저 대답해 보세요. 떠올리려고 애쓰는 그 순간에 기억이 만들어집니다.
SQL 에러 중 '에러가 안 나는 것이 더 위험하다'는 말의 예를 들어보세요.
ORDER BY '컬럼명'(정렬이 안 됨), LIMIT3(제한이 안 걸림), NOT IN에 NULL(0행), sal - 400(더하라는데 뺌) 같은 것들입니다. 전부 실행은 성공하고 결과만 틀립니다 — 그래서 아무도 눈치채지 못한 채 지나갑니다.
결과가 예상과 다를 때 가장 먼저 확인할 것은?
행 수입니다. 예상보다 적으면 INNER JOIN이나 NULL 조건을, 많으면 1:N 조인이나 카티시안 곱을 의심합니다. 0행이면 NOT IN의 NULL이나 BETWEEN 순서(작은 값이 앞이어야 한다 — BETWEEN 10 AND 6은 0행)를 봅니다. 행 수만 봐도 원인이 크게 좁혀집니다.
💼 실무·코딩테스트에서는에러 메시지를 읽는 능력이 곧 디버깅 실력입니다. SQL은 번호가 정해져 있어 특히 유리하죠. 실무에서는 로그에 쿼리와 에러 번호가 함께 남으므로, 번호만 봐도 원인을 짐작할 수 있으면 대응이 훨씬 빨라집니다.
✏️ 시험 대비 — Java · SQL 객관식 연습문제
자바 교안 전 범위(기초·객체지향·활용·네트워크)와 SQL 30문항 · 웹 개발 55문항까지, 총 187문항의 객관식 문제를 한 문제씩 풀면서 고르는 즉시 정답과 해설을 확인합니다. 짧은 해설 아래의 🔎 자세한 해설을 펼치면 보기 ①~④를 하나씩 왜 맞고 왜 틀렸는지 짚어 주고, 마지막에 더 쉬운 말로 정리한 설명까지 붙습니다(틀린 문제는 자동으로 펼쳐집니다). 마음에 걸리는 문제는 ⭐ 즐겨찾기 · 🔥 어려움으로 표시해 두면 나중에 표시한 것만 · 틀린 것만 골라 다시 풀 수 있어요. 범위 칩에서 🗄️ SQL이나 🖥️ 웹 개발만 골라 그 과목만 풀 수도 있습니다. 문제은행은 교안 전 범위라 아직 배우지 않은 단원도 섞여 나오는데, 범위에서 📅 배운 데까지를 고르면 수업에서 진도가 나간 개념만 출제됩니다(진도 카드가 늘어나면 범위도 함께 넓어집니다).
분야
범위
문제 수
⌨️ 키보드로도 풀 수 있어요 — 숫자 1~4로 보기 선택, Enter로 다음 문제, S 즐겨찾기, D 어려움 표시.