자바 교안 전 범위(기초 문법 → 객체지향 → 활용 → 네트워크)와 실습과제 12문제를 한 줄 요약 → 쉽게 말하면(비유) → 개념 설명 → 주석 달린 코드 → 핵심 정리 순서로 미리 정리해 둔 예습·복습 노트입니다. 카드는 처음엔 요약만 보이게 접혀 있어요 — 훑어보다가 궁금한 항목만 펼치면 됩니다.
마지막 업데이트: 2026-08-06📅 수업 진도 4일차☕ 자바 기초 11개🧱 객체지향 24개🛠️ 자바 활용 11개🌐 네트워크 8개🧪 실습과제 12문제🧭 학습 여정 13단계📖 개념 사전 24개✏️ 시험 대비 102문제
완료 체크·즐겨찾기·메모·복습·시험 기록은 이 브라우저에만 저장됩니다(프론트엔드 노트와는 별도로 저장돼 서로 섞이지 않습니다). 캐시를 지우거나 다른 기기로 옮기면 사라지니, 가끔 내보내기로 백업해 두고 필요할 때 가져오기로 복원하세요.
📖 이 노트로 예습·복습하는 방법
이 노트는 수업보다 먼저 만들어 둔 예습용입니다. 교안 197장과 실습과제 12문제를 전 범위 정리했으니, 수업에서 진도가 나간 부분을 골라 읽으면 복습 노트로도 그대로 쓸 수 있습니다.
📅 수업 진도 탭에는 수업에서 직접 작성한 실습 코드가 날짜별로 정리돼 있습니다. "오늘 어디까지 나갔는지"와 "그게 어느 개념 카드로 이어지는지"를 한눈에 볼 수 있어 시험 범위를 가늠하기 좋습니다.
🧭 학습 여정 탭은 교안 전 범위를 "왜 이 순서로 배우는가" 하나의 흐름으로 엮은 13단계 지도입니다. 지금 배우는 내용이 앞의 무엇 위에 서 있고 뒤의 무엇으로 이어지는지 헷갈릴 때 여기부터 보세요. 📖 개념 사전 탭은 진도와 상관없이 용어 하나만 찾아보는 곳이고, 특히 그룹 D는 빨간 에러 메시지를 그대로 찾는 사전입니다 — 코드가 안 돌아가면 NullPointer처럼 메시지 일부를 검색창에 쳐 보세요.
순서대로 본다면 ☕ 자바 기초 → 🧱 객체지향 → 🛠️ 자바 활용 → 🌐 네트워크 흐름을 따르세요. 기초는 "변수·연산·제어문", 객체지향은 "클래스가 메모리에 올라가는 방식", 활용은 "컬렉션·예외·입출력·스레드", 네트워크는 "소켓으로 서버 만들기"가 중심입니다.
카드는 처음에 접혀 있고 한 줄 요약과 쉽게 말하면(일상 비유)만 보입니다. 이 두 줄만 훑어도 전체 흐름이 잡혀요.
🧪 실습과제 탭에는 자바실습과제 12문제(약수·최대공약수·친화수·완전수·윤년·개미수열·로또·마방진·야구게임·달력·카드)의 풀이 단계와 핵심 코드가 정리돼 있습니다. 먼저 직접 풀어보고 막힐 때 펼쳐 보세요.
객관식 시험을 앞두고 있다면 ✏️ 시험 대비 탭에서 문제를 풀어보세요. 한 문제씩 고르는 즉시 정답·해설이 나오고, 🔎 자세한 해설을 펼치면 보기 네 개를 하나씩 짚어 주는 풀이와 더 쉬운 정리가 이어집니다. ⭐ 즐겨찾기 · 🔥 어려움으로 표시해 둔 문제나 틀린 문제만 골라 다시 푸는 것도 됩니다. 아직 진도가 안 나간 단원까지 섞여 나오는 게 부담이라면 범위에서 📅 배운 데까지를 고르세요 — 수업 진도 탭에 정리된 만큼만 출제됩니다.
카드 제목 옆 완료 체크박스·☆ 버튼(헷갈리는 카드)·🔗 버튼(카드 링크 복사)을 활용하고, 하단 📝 내 메모 칸에 헷갈린 점을 적어두세요. 우하단 ⚡ 5분 복습은 카드의 "쉽게 말하면"만 뽑아 플래시카드로 넘겨 줍니다.
프론트엔드(HTML·CSS·JS·React) 노트는 🎨 프론트엔드 학습 노트에 따로 있습니다.
📅 수업 진도 — 실제로 배운 것
수업에서 직접 작성한 실습 코드를 날짜별로 정리합니다. 아래 개념 카드들이 "교안 전 범위 예습"이라면, 이 탭은 "오늘 실제로 진도가 나간 부분"이라서 시험 범위를 가늠하는 기준이 됩니다. 각 항목에서 관련 개념 카드로 바로 이동할 수 있어요.
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에 저장된다는 것을 확인했습니다. 그리고 "main은 이미 static으로 메모리에 올라가 있어서, static이 없는 메서드를 여기서 바로 호출하면 안 된다"는 규칙을 testMethod()를 static으로 선언하는 것으로 실습했습니다. → 🧱 05. static vs non-static과 메모리 3영역
③ VariableTest — 기본타입의 표현 범위
byte(1byte, -128 ~ 127) · short(2byte) · int(4byte) · long(8byte)의 크기를 직접 대입해 보며 확인했고, 리터럴 정수는 기본이 int이므로 long 범위의 값에는 L을 붙여야 한다는 것을 배웠습니다. 실수는 기본이 double이라 float에는 f를 붙여야 하고, 안 붙이면 "double 값을 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)는 컴파일 시점에 확정되어 그냥 담긴다.
TIP실습 코드 주석 중 사실만 살짝 다르게 적힌 두 곳이 있어 시험 전에 바로잡아 두면 좋습니다 — ① byte bb = (byte) i;의 주석은 "byte → int"로 되어 있지만 실제 방향은 int → byte입니다(큰 타입에서 작은 타입으로 가야 다운캐스팅이므로 값 손실도 이때 생깁니다). ② float f = 15.77f;의 주석 "8byte크기라 f 붙여줌"에서, float은 4byte이고 8byte는 double입니다 — f를 붙이는 이유는 "실수 리터럴의 기본 타입이 double(8byte)이라서 그대로 두면 float에 담을 수 없기 때문"입니다. 코드 동작에는 영향이 없지만 객관식에서 정확히 갈리는 부분입니다.
한 줄 요약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 메뉴뿐입니다. 예금액 : 뒤가 비어 보이는 건 키보드로 친 값이 화면에 다시 찍히지 않기 때문이고, 흐름은 예금 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부터 시작해 가운데 줄이 두 번 찍히지 않는다는 걸 출력에서 확인할 수 있습니다.
switch의 각 case에는 break가 필요하고, 없으면 아래로 흘러내려간다(fall through). 나머지는 default가 받는다.
메뉴 반복은 while (플래그)로 감싸고 종료 시 플래그를 false로 바꿔 빠져나온다.
별 찍기: 바깥 for = 줄, 안쪽 for = 개수. 피라미드는 공백 n-1-i개 + 별 2i+1개.
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만 정하면 됩니다. 그래서 for (int i = a; i > 0; i--)로 거꾸로 도는 역피라미드도 for (int i = 0; i < 5; i++)로 두고 개수 식만 (i+1)*2-1로 바꾸면 똑같이 나옵니다 — 마지막 블록이 바로 그걸 확인한 코드입니다. 반복 방향이 아니라 식이 본질이라는 감각을 잡은 셈입니다.
③ D2_MethodTest — main은 진입점, 그리고 static의 벽
main은 프로그램이 가장 먼저 들어오는 문(진입점)이고, 다른 메서드는 여기서 불러 줘야 실행됩니다. 그런데 main은 static이라 이미 메모리에 올라가 있고, 아직 메모리에 없는 non-static 메서드를 직접 부를 수 없습니다. 그래서 test02()를 쓰려면 new D2_MethodTest()로 객체를 만들어 methodTest.test02()로 호출해야 합니다. 반대로 test01()은 static이므로 클래스명.메서드()로 객체 없이 바로 부를 수 있죠. 1일차에 testMethod()에 static을 붙여 해결했던 그 문제를, 이번엔 객체를 만드는 쪽으로 풀어 본 것입니다. → 🧱 05. static vs non-static과 메모리 3영역
④ 메서드 유형 — (매개변수 O/X) × (반환값 O/X)
메서드는 결국 네 종류로 나뉩니다. test04()처럼 받지도 돌려주지도 않고 실행만 하는 void형, test03()처럼 받지 않고 돌려주기만 하는 형, test05(int a, int b)처럼 받아서 계산해 돌려주는 형이 대표적입니다. 반환타입을 void가 아닌 것으로 선언했다면 모든 경로에서 반드시 그 타입을 return 해야 하고, 그러지 않으면 컴파일 에러가 납니다. test05는 두 수 중 큰 값을 돌려주는데, 여기서 res를 미리 0으로 초기화해 둔 것도 "지역변수는 초기화하지 않으면 쓸 수 없다"는 규칙 때문입니다. → 🧱 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를 쓴다.
메서드 유형 = (매개변수 있음/없음) × (반환값 있음/없음) 네 가지 조합.
TIPtest03() 위에 달린 주석 // 매개변수 없음, 반환값 없음은 실제 코드(public int test03())와 어긋납니다 — 매개변수는 없지만 반환값은 있는 형이죠. 주석을 코드와 함께 고치는 습관이 시험에서도, 나중 협업에서도 그대로 점수가 됩니다. 그리고 test05의 if~else는 삼항연산자로 return a > b ? a : b; 한 줄로 줄일 수 있습니다. 2일차 별 찍기에서 (i == a) ? i : i + ","를 이미 써 봤으니, 같은 도구를 반환에도 쓸 수 있다는 걸 기억해 두세요.
한 줄 요약문법에서 객체지향으로 넘어간 날 — 멤버필드가 객체마다 따로인지(인스턴스) 클래스가 공유하는지(static)를 눈으로 확인하고, 생성자 오버로딩과 this()·super(), private + getter/setter로 감싸는 캡슐화, 모든 클래스의 부모인 Object의 getClass · toString · hashCode · equals까지 훑었고, 실습과제의 약수 · 최대공약수 · 최소공배수 · 친화수 · 완전수를 메서드로 분리해 한 파일에 구현했다.
쉽게 말하면클래스는 붕어빵 틀, 객체는 그 틀로 구워 나온 붕어빵 하나하나입니다. 생성자는 굽는 순간 "팥이요, 슈크림이요"를 정하는 주문서고, 생성자 오버로딩은 주문서 양식을 여러 개 만들어 두는 것(아무 말 없으면 기본 맛, 크기만 말하면 크기만 바꿔 굽기). private은 "속 재료는 손대지 마세요"라고 포장해 두는 것이고, getter/setter는 그 포장을 여는 정해진 창구입니다. 창구를 거치게 만들면 "비밀번호를 아는 사람에게만 알려준다" 같은 조건을 끼워 넣을 수 있어요.
① D2_ClassTest — 인스턴스 변수 vs static 변수
같은 클래스에 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·D4_Constructor 둘 다 이 이유로 기본 생성자를 명시했습니다). 그리고 기본 생성자 안에서 this(10), this(24, "검정색")처럼 자기 자신의 다른 생성자를 호출해 초기화 로직을 한 곳에 모았습니다 — 이때 this(...)는 반드시 생성자의 첫 줄이어야 합니다. 파라미터 이름과 필드 이름이 같을 때 this.size = size;로 구분하는 것도 같은 this지만 "이 객체 자신의 필드"라는 다른 쓰임입니다. → 🧱 07. 생성자와 생성자 오버로딩 · 🧱 06. 오버로딩
③ super()와 this()는 함께 쓸 수 없다
D1_divisor의 생성자에 super();를 직접 써 보고, 그 아래 // this(); // 같이 작성할 수 없다를 주석으로 남긴 부분이 오늘의 중요한 한 줄입니다. 생성자의 첫 줄에는 super(...)나 this(...) 둘 중 하나만 올 수 있습니다(둘 다 "첫 줄" 자리를 요구하니까요). 아무것도 쓰지 않으면 컴파일러가 super()를 몰래 넣어 줍니다. 그런데 부모를 선언한 적이 없는데 무슨 부모냐 하면 — 자바의 모든 클래스는 자동으로 Object를 상속하기 때문입니다. → 🧱 20. this와 super, 참조타입 형 변환
④ D4_Constructor — private과 getter/setter (캡슐화)
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
getClass()는 패키지.클래스명을, toString()은 재정의하지 않았을 때 패키지.클래스명@해시코드(16진수)를, hashCode()는 같은 값을 10진수로 돌려줍니다. 그리고 equals()는 기본이 주소 비교라서 ot.equals(str2)가 false인 게 당연합니다. 여기서 String이 특별한 이유가 드러나는데, String은 equals를 "내용 비교"로 재정의해 두었기 때문입니다. 이어서 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 — 실습과제를 "메서드로 쪼개서" 풀기
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(...) 또는 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), 친화수는 서로의 진약수 합이 상대가 되는 쌍(자기 자신은 제외).
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에서 정상 종료하니, 실전에서는 이쪽이 안전합니다.
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 버전 이상이어야 한다.
실행할 때는 파일 이름이 아니라 패키지명.클래스명 전체 이름을 쓴다.
TIP이클립스나 VS Code를 쓰면 저장할 때마다 자동으로 컴파일되기 때문에 javac를 직접 칠 일이 거의 없습니다. 하지만 "왜 .class 파일이 bin 폴더에 생기지?"를 이해하려면 이 명령어 흐름을 한 번은 손으로 쳐보는 게 좋습니다.
02
명명법 · 식별자 규칙 · 주석
PascalCamel상수예약어주석
한 줄 요약클래스·생성자·인터페이스는 파스칼(HelloJava), 변수·메서드는 카멜(printKey), 상수는 전부 대문자(MAX_VALUE), 패키지는 전부 소문자로 쓰며, 식별자에는 공백·특수문자(_ $ 제외)·숫자 시작·예약어를 쓸 수 없다.
쉽게 말하면이름 짓는 규칙은 옷차림 규정이에요. 클래스는 정장(첫 글자 대문자 Pascal), 변수·메서드는 캐주얼(첫 글자 소문자 Camel), 상수는 대문자로 소리치듯(MAX_SIZE), 패키지는 소문자로 조용히. 컴파일러는 규정을 어겨도 대부분 통과시켜 주지만, 사람이 읽을 때 바로 "이게 클래스구나/변수구나"를 알아채게 해 주는 약속입니다.
① 공백 금지 — 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 + 전부 대문자 (스네이크 방식)
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);
}
}
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도 사라진다
}
핵심 정리
변수는 값 하나만 담고, 여러 번 대입하면 마지막 값만 남는다.
=는 "같다"가 아니라 "오른쪽 값을 왼쪽에 넣는다"는 대입 연산자다.
상위 블록의 변수 → 하위 블록에서 사용 가능 / 하위 블록의 변수 → 상위 블록에서 사용 불가.
반환하거나 반복문 밖에서 써야 할 값은 반복문 바깥에 선언한다.
TIP"변수를 찾을 수 없습니다(cannot find symbol)" 컴파일 에러의 절반 이상은 스코프 문제입니다. 에러가 나면 그 변수를 어느 중괄호 안에서 선언했는지부터 확인하세요.
04
기본타입 8가지와 메모리
primitiveintdoublecharboolean스택
한 줄 요약기본타입은 정수형(byte·short·int·long)·실수형(float·double)·문자형(char)·논리형(boolean) 8가지이며 스택 메모리에 값 자체가 저장되고 불변(immutable)이다.
쉽게 말하면기본타입은 크기가 정해진 택배 상자예요. byte는 손바닥만 한 상자(1바이트), int는 중간 상자(4바이트), long은 대형 상자(8바이트). 큰 상자에 작은 물건을 넣으면 남는 공간이 낭비되고, 작은 상자에 큰 물건은 아예 안 들어갑니다. 그래서 "이 값이 얼마나 커질 수 있는가"를 보고 상자 크기를 고릅니다.
8가지 기본타입과 크기
정수형: byte(1byte) · short(2byte) · int(4byte) · long(8byte). 실수형: float(4byte) · double(8byte). 문자형: char(2byte, 유니코드 문자 1개). 논리형: boolean(1byte, true/false). 정수형의 표현 범위는 부호 비트 1개를 빼고 계산해서 -2ⁿ⁻¹ ~ 2ⁿ⁻¹-1이 됩니다 — byte는 -128 ~ 127, int는 약 ±21억입니다.
초기값 (멤버필드로 선언했을 때)
정수형은 0(long은 0L), 실수형은 0.0(float은 0.0F), char는 '\u0000', boolean은 false, 참조형은 null로 자동 초기화됩니다. 단 지역변수는 자동 초기화되지 않으므로 반드시 직접 값을 넣어야 컴파일됩니다.
스택에 저장되고 불변이다
기본타입은 스택(Stack) 메모리에 값 그 자체가 저장되며, 스택은 LIFO(Last In First Out) 구조라 나중에 들어온 것이 먼저 나갑니다. 그래서 블록이 끝나면 그 블록의 변수가 즉시 정리됩니다. 또 기본타입은 immutable(불변)이라, 메서드에 넘겨도 원본 값이 바뀌지 않습니다(값 복사 = 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를 붙인다.
기본타입은 스택에 값이 그대로 저장되며 불변(immutable)이다.
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로 바꾸는 단순한 방법을 쓰면 계산이 어긋나기 때문에(그렇게 하면 -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를 쓰는 이유가 여기 있습니다.
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을 더하면 최소값으로 돌아간다(오버플로).
TIP실무에서 2의 보수를 직접 계산할 일은 드물지만, "왜 int 최대값에 1을 더했더니 음수가 나오지?"라는 버그를 만났을 때 원인을 아는 사람과 모르는 사람의 차이가 큽니다. 큰 수를 다룰 땐 long이나 BigInteger를 고려하세요.
06
형 변환(캐스팅)과 타입 변환 총정리
업캐스팅다운캐스팅parseIntvalueOfBoxing
한 줄 요약작은 타입 → 큰 타입은 자동(업캐스팅), 큰 타입 → 작은 타입은 (byte)처럼 직접 명시(다운캐스팅)해야 하며, 연산 시에는 항상 더 큰 타입으로 맞춰진 뒤 계산된다.
쉽게 말하면업캐스팅은 작은 컵의 물을 큰 컵에 붓는 것이라 아무 문제 없이 자동으로 됩니다. 다운캐스팅은 큰 컵의 물을 작은 컵에 붓는 것이라 넘칠 수 있어서, "넘쳐도 내가 책임진다"고 (byte)처럼 서명해야 합니다.
업캐스팅 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로 계산되므로 가능합니다. ② 실수 연산에서는 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부터 자동으로 처리됩니다(오토박싱).
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
Object o = un; // 기본타입도 Object에 담을 수 있다(오토박싱)
int back = (Integer)o; // 꺼낼 땐 캐스팅 필요
System.out.println(box + " " + un + " " + back);
}
}
핵심 정리
작은 → 큰 = 업캐스팅(자동), 큰 → 작은 = 다운캐스팅(명시적 (타입) 필요, 값 손실 가능).
int보다 작은 정수 타입끼리 연산하면 결과는 int — byte + byte를 byte에 담으면 에러.
정수 + 실수 = 실수. 크기와 무관하게 실수형이 더 넓은 범위를 표현한다.
문자열 → 숫자는 parseInt/parseDouble, 숫자 → 문자열은 valueOf 또는 + "".
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; 형태로 쓴다.
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·enum)과 String(자바 1.7부터)입니다 — long이나 double은 쓸 수 없습니다.
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 계열(int·byte·short·char·enum)과 String(1.7+). long·double은 불가.
break를 빼면 아래 case로 계속 흘러내려간다(fall through) — 실수이자 활용법.
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는 남은 코드를 건너뛰고 반복문의 선두(증감식)로 점프합니다 — 특정 값만 처리에서 제외하고 싶을 때 씁니다.
라벨(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문에서 증감식을 빠뜨리면 무한 루프가 되므로 항상 확인한다.
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("그는 \"자바\"라고 말했다.");
}
}
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();
// ---- 방법 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));
}
}
핵심 정리
System.in은 바이트라서 Scanner나 InputStreamReader로 감싸 문자로 바꿔 쓴다.
nextInt() 뒤에 nextLine()을 쓰면 엔터 때문에 건너뛴다 → nextLine()을 한 번 더 호출해 비운다.
BufferedReader는 빠르지만 문자열만 주므로 Integer.parseInt()로 변환하고 예외 처리가 필요하다.
TIP실습과제의 야구게임·달력(생일 입력)처럼 사용자 입력을 반복해서 받는 문제에서는 Scanner 객체를 메서드마다 새로 만들지 말고 하나만 만들어 재사용하세요. 여러 개 만들면 버퍼가 꼬여 입력이 씹히는 일이 생깁니다.
🧱 2. 객체지향 프로그래밍
클래스·객체·메서드라는 용어 정리에서 시작해 static과 메모리 구조, 생성자와 오버로딩, String과 배열, 상속·은닉성·다형성이라는 OOP 3대 개념, 추상클래스·인터페이스·제네릭까지 — 자바의 중심이 되는 단원입니다.
01
클래스 · 객체 · 인스턴스와 용어 정리
classinstance멤버필드지역변수new
한 줄 요약클래스는 설계도이고 new로 그 설계도를 heap 메모리에 실제로 찍어낸 것이 인스턴스이며, 만들어진 객체의 주소(reference)를 변수에 담아 그 주소로 멤버에 접근한다.
쉽게 말하면클래스는 붕어빵 틀, 객체(인스턴스)는 그 틀로 구운 붕어빵이에요. 틀 하나로 붕어빵 100개를 만들 수 있고, 붕어빵마다 팥·슈크림처럼 내용물(멤버필드 값)이 다를 수 있습니다. A a = new A();는 "붕어빵을 하나 굽고(new), 그 붕어빵이 놓인 자리 번호(주소)를 a라는 쪽지에 적어둔다"는 뜻입니다.
클래스 · 메서드 · 객체
클래스(Class)는 틀·설계도이며 사용자 정의 타입(User-defined type)입니다. 메서드(Method)는 그 안에 정의된 기능이고, 객체(Object)는 존재하는 모든 것을 가리키는 포괄적 개념으로 객체지향에서는 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 명명법)
Byte b = Byte.MAX_VALUE; // ┐
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에 만들어진 실체, 객체 변수 = 그 주소를 담은 쪽지.
클래스는 heap 영역에 덩어리로 할당되고, 객체는 reference를 통해서만 사용할 수 있다.
멤버필드(인스턴스 변수/클래스 변수) vs 지역변수 vs 매개변수 — 선언 위치로 구분한다.
생성자는 클래스명과 이름이 같고 리턴 타입이 없다.
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()는 객체를 판별하는 고유값이며 객체를 생성할 때마다 새로 만들어집니다. 그래서 내용이 같은 두 객체를 각각 new로 만들면 hashCode가 서로 다릅니다. equals()도 기본 구현은 결국 주소 비교라서 두 객체는 false가 나옵니다. 단 String은 예외로, 값에 따라 hashCode와 equals가 이미 오버라이드되어 있어 값이 같으면 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진수해시코드가 출력된다.
hashCode는 객체를 생성할 때마다 새로 생기므로, 기본 equals는 사실상 주소 비교다.
String은 예외적으로 값 기준으로 equals·hashCode가 이미 오버라이드되어 있다.
equals를 재정의하면 hashCode도 반드시 함께 재정의한다.
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): 매개변수 목록. 이 다섯 조각의 순서는 고정입니다.
리턴 타입 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
}
}
핵심 정리
메서드는 클래스 안에만 정의할 수 있고, 메서드 안에 메서드를 정의할 수는 없다.
선언 순서: 접근제한자 → static → 리턴타입 → 메서드명 → (매개변수).
parameter는 받는 쪽 변수(그릇), argument는 넘기는 실제 값.
리턴 타입이 void가 아니면 모든 실행 경로에서 값을 return 해야 컴파일된다.
TIP"이 메서드가 뭘 하는가"를 한 문장으로 말할 수 없다면 메서드가 너무 큰 것입니다. 메서드 하나에 기능 하나가 원칙이고, 실습과제를 풀 때도 "약수 구하기 / 합 구하기 / 판별하기"를 각각 메서드로 나누면 코드가 훨씬 단순해집니다.
04
접근 제한자 4가지
privatedefaultprotectedpublic
한 줄 요약private(같은 클래스) → default(같은 패키지) → protected(같은 패키지 + 상속받은 자식) → public(어디서나) 순으로 접근 범위가 넓어지며, 데이터를 감추기 위해 필드는 private으로 두는 것이 기본이다.
쉽게 말하면접근 제한자는 집의 출입 권한이에요. private은 내 방(나만 들어감), default는 우리 집(같은 패키지 식구만), protected는 우리 집 + 분가한 자식(상속받은 클래스는 밖에 있어도 들어옴), public은 공원(누구나)입니다.
4가지 범위
private (-): 같은 클래스 안에서만 접근·참조 가능. (default): 아무것도 안 쓴 상태로, 같은 패키지 안에서만 접근 가능. protected (#): 같은 패키지에서 접근 가능하고, 상속받은 자식 클래스라면 다른 패키지에서도 접근 가능. 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 + 메서드로 접근하는 것이 은닉화의 기본 형태다.
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 멤버는 애플리케이션이 실행되면 모두 메모리에 할당되고 종료될 때 삭제됩니다. 객체를 생성하지 않고 클래스명.메서드명()으로 호출합니다. 반대로 non-static 멤버는 인스턴스를 생성할 때 만들어지며객체명.메서드명()으로 호출합니다.
핵심 규칙: static → non-static은 불가
static은 non-static을 직접 사용할 수 없습니다(객체를 생성하면 가능). 반대로 non-static은 static을 자유롭게 사용할 수 있습니다. 이유는 시점 차이입니다 — static이 메모리에 올라간 시점에는 인스턴스가 아직 존재하지 않을 수 있기 때문입니다. main이 static이라서 "main에서 일반 메서드를 바로 호출하면 에러가 나는" 익숙한 문제가 바로 이 규칙입니다.
메모리 3영역
Method Area(= static 영역): 클래스 정보, static 필드·메서드, 상수 풀을 저장합니다. 흔히 "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로 만든 객체.
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가 이 패턴을 많이 쓴다.
오버로딩(같은 클래스, 매개변수 차이) ≠ 오버라이딩(상속 관계, 재정의).
TIP생성자도 오버로딩할 수 있습니다 — 07. 생성자에서 "기본형 TV / 크기 지정 TV / 크기+색상 지정 TV"를 만드는 예제가 바로 생성자 오버로딩입니다.
07
생성자(Constructor)와 생성자 오버로딩
Constructordefault 생성자this()super()인스턴스 블록
한 줄 요약생성자는 클래스명과 이름이 같고 리턴 타입이 없으며 객체 생성 시 딱 한 번 호출되고, 매개변수를 달리해 여러 개 만들 수 있으며(생성자 오버로딩), 상속 관계에서는 자식이 만들어지기 전에 부모가 먼저 만들어진다.
쉽게 말하면생성자는 제품 출고 전 초기 세팅이에요. 새 휴대폰을 켜면 언어·시간대를 자동으로 맞춰 주듯, 객체가 만들어지는 순간 딱 한 번 실행되어 기본값을 채웁니다. 아무 세팅도 안 적어두면 공장 기본값(default 생성자)이 자동으로 들어가고, 내가 세팅을 하나라도 적으면 기본값은 더 이상 자동으로 만들어지지 않습니다.
생성자의 5가지 성질
① 외부에서 객체 생성 시 딱 한 번 호출된다. ② 클래스를 만들면 매개변수 없는 default 생성자가 자동 생성된다(단, 생성자를 하나라도 직접 만들면 자동 생성되지 않는다). ③ 접근제한자 + 클래스명으로 구성되고 이름이 클래스명과 같다. ④ 리턴 타입이 없다(void도 안 쓴다). ⑤ 부모의 생성자는 물려받지 못하며 오버라이딩도 금지된다.
자생부생 — 자식이 생성되려면 부모가 먼저
상속 구조에서 자식 객체를 만들면 부모 생성자가 먼저 실행됩니다. 이를 "자생부생(자식 생성, 부모 생성)"이라 부릅니다. 명시적으로 쓰지 않으면 컴파일러가 자식 생성자 첫 줄에 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()(부모 생성자)·this()(같은 클래스의 다른 생성자)는 반드시 첫 줄에 온다.
실행 순서: static 블록 → 인스턴스 블록 → 생성자.
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 패키지.*는 그 패키지의 클래스만 가져오며, 하위 패키지까지 가져오지는 않는다.
TIP이클립스에서 Ctrl+Shift+O(Organize Imports)를 누르면 필요한 import를 자동으로 추가하고 안 쓰는 것은 지워 줍니다. VS Code에서도 클래스명을 쓰면 자동 import를 제안합니다.
09
기본타입 vs 참조타입 — 값 전달과 주소 전달
pass by valuepass by referenceimmutablemutable
한 줄 요약기본타입과 String은 값이 복사되어 전달되므로(pass by value) 메서드 안에서 바꿔도 원본이 그대로지만, String을 제외한 참조타입은 주소가 전달되어(pass by reference) 메서드 안에서 필드를 바꾸면 원본도 바뀐다.
쉽게 말하면기본타입을 넘기는 건 서류를 복사해서 주는 것이고, 참조타입을 넘기는 건 창고 열쇠를 복사해서 주는 것이에요. 복사한 서류에 낙서해도 원본은 멀쩡하지만, 복사한 열쇠로 창고에 들어가 물건을 바꾸면 원래 창고의 물건이 진짜 바뀝니다. String은 참조타입인데도 서류처럼 동작하는 특별한 예외입니다.
두 타입의 저장 위치와 성질
기본타입은 Stack에 값이 저장되고 immutable(불변)하며, 대입도 전달도 값 복사입니다. 참조타입은 실제 객체가 Heap에 저장되고 변수에는 그 주소가 담기며 mutable(가변)합니다. 참조타입에는 API 클래스, 배열, 사용자 정의 클래스가 있으며, 기본타입들을 묶어 새로운 타입을 만드는 역할을 합니다.
String만은 예외 — immutable
String은 참조타입이지만 immutable합니다. 메서드에 String을 넘기고 그 안에서 st = "안녕하세요";로 바꿔도 호출한 쪽의 값은 그대로입니다. String을 "수정"하는 모든 연산은 실제로는 새 객체를 만들어 반환하기 때문입니다(12. String의 특징). 그래서 "값에 의한 전달"이 되는 것은 모든 기본타입 + String이고, "참조에 의한 전달"은 String을 제외한 참조타입입니다.
실전에서 헷갈리는 지점
참조타입을 넘겨받은 메서드가 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: 참조타입이지만 immutable → 원본 불변
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가 아니다 — 새 객체를 대입했을 뿐)
}
}
핵심 정리
기본타입 = Stack + 값 저장 + immutable / 참조타입 = Heap + 주소 저장 + mutable.
값에 의한 전달(pass by value) = 모든 기본타입 + String.
참조에 의한 전달(pass by reference) = String을 제외한 모든 참조타입.
참조타입도 매개변수에 새 객체를 대입하면 원본은 바뀌지 않는다.
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)와 고유값(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가 반드시 필요하다.
TIPInteger끼리 ==로 비교하면 -128~127 범위는 캐싱되어 true, 그 밖은 false가 나오는 함정이 있습니다. Wrapper 값 비교는 항상 equals()나 intValue()로 하세요.
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(주소 비교)와 obj1.equals(obj2)가 모두 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이라 객체를 만들 수 없기 때문.
같은 인스턴스인지는 ==(주소 비교)로 확인할 수 있다.
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은 참조타입 중 유일하게 기본타입의 특징을 가집니다. ① new 없이 String str = "string";으로 객체를 만들 수 있고 ② immutable해서 값을 바꾸는 연산은 항상 새 객체를 만들며 ③ 값에 따라 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는 둘이 같지만 주소는 다릅니다.
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 — 값(hashCode)은 같다
// ---- 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를 쓴다.
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/양수로 반환한다.
TIPtoCharArray()는 🧪 개미수열처럼 "앞 글자와 다음 글자를 비교하며 세는" 문제에서 핵심 도구입니다. 문자열을 배열로 바꾸면 인덱스로 앞뒤를 자유롭게 비교할 수 있습니다.
14
String 비교 — == 와 equals의 차이
==equalsreferencehashCode
한 줄 요약==는 주소(reference)를 비교하고 equals()는 값(hashCode)을 비교하므로, 문자열 내용을 비교할 때는 반드시 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가 통하는가
일반 객체는 new 할 때마다 hashCode가 새로 생기므로 Object의 기본 equals(사실상 주소 비교)로는 값 비교가 불가능합니다. 하지만 String은 값에 따라 hashCode가 정해지도록 Object의 메서드를 오버라이드해 두었기 때문에 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 (주소 01 == 01)
System.out.println(str1.equals(str2)); // true (해시 11 == 11)
// ② new vs new — 주소가 각각 다르다
String obj1 = new String("java");
String obj2 = new String("java");
System.out.println(obj1 == obj2); // false (주소 02 != 03)
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 — 안전하다
}
}
핵심 정리
==는 주소 비교, equals()는 값(hashCode) 비교 — 내용 비교는 항상 equals.
리터럴끼리는 pool을 공유해 ==도 true지만, 이것에 의존하면 안 된다.
new가 하나라도 끼면 ==는 false가 된다.
"리터럴".equals(변수) 순서로 쓰면 NullPointerException을 예방할 수 있다.
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
저장 영역: String은 Constant String Pool, 나머지 둘은 Heap. 변경 가능성: String은 immutable, 나머지 둘은 mutable. 안정성: StringBuilder는 동기화를 지원하지 않아 멀티스레드에 취약하지만 빠르고, StringBuffer는 동기화를 지원해 스레드에 안전하지만 느립니다. 일반적인 단일 스레드 코드에서는 StringBuilder를 쓰면 됩니다.
mutable의 의미
immutable은 "바꾼 결과를 다시 대입하지 않으면 원래 문자열 그대로"이고, mutable은 "다시 대입하지 않아도 원래 것이 바뀐다"는 뜻입니다. 그래서 sb.append("K")만 해도 sb의 내용이 바뀌며, StringBuffer의 hashCode는 내용이 바뀌어도 그대로 유지됩니다(같은 객체이므로). 출력할 때는 toString()으로 String으로 바꿔서 씁니다.
split — 빈 칸도 유지
String.split(구분자)는 구분자로 잘라 배열로 반환합니다. 구분자가 연속으로 나오면 빈 문자열도 배열 요소로 들어갑니다 — "100,200,300,,,400".split(",")은 길이 6짜리 배열이 되고 중간에 빈 값 두 개가 들어 있습니다. 정규식을 구분자로 쓸 수 있다는 것도 특징입니다.
StringTokenizer — 빈 칸 무시
java.util.StringTokenizer는 일정한 토큰으로 잘라 순차적으로 꺼내 쓰며, 빈 토큰은 아예 만들지 않습니다. hasMoreElements()로 남았는지 확인하고 nextToken()으로 하나씩 꺼냅니다. "빈 값을 무시하고 싶다면 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()); // 뒤집기도 가능
// ---- 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.hasMoreElements()) {
System.out.print("[" + st.nextToken() + "]"); // [100][200][300][400]
}
}
}
split은 빈 문자열도 배열 요소로 남기고, StringTokenizer는 빈 토큰을 무시한다.
StringBuilder/Buffer는 출력 전에 toString()으로 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)이며 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(괄호 없음), 한 번 정한 크기는 바꿀 수 없다.
TIP배열은 크기가 고정이라 중간에 늘릴 수 없습니다. 개수가 유동적이라면 ArrayList를 쓰세요 — 🧪 로또 문제에서 Lotto는 배열(항상 6개), LottoStore는 매수가 달라지므로 배열이나 ArrayList를 쓰는 식으로 나뉩니다.
17
향상된 for문과 얕은 복사 · 깊은 복사
Enhanced forShallow CopyDeep Copyarraycopyclone
한 줄 요약향상된 for문은 인덱스 없이 요소를 순회하고, 배열을 그냥 대입하면 주소만 복사되어(얕은 복사) 한쪽을 바꾸면 둘 다 바뀌므로 독립적인 복사본이 필요하면 System.arraycopy나 clone()을 써야 한다.
쉽게 말하면얕은 복사는 같은 문서의 링크를 공유하는 것이고 깊은 복사는 문서를 통째로 인쇄해 주는 것이에요. 링크를 받은 사람이 내용을 고치면 원본도 바뀌지만, 인쇄본을 받은 사람이 낙서해도 원본은 멀쩡합니다.
향상된 for문(Enhanced for)
for (타입 변수 : 배열또는컬렉션) { } 형태로, 인덱스를 쓰지 않고 요소를 하나씩 꺼내 순회합니다. 코드가 짧고 인덱스 실수(off-by-one)가 없다는 장점이 있지만, 인덱스를 알 수 없고 요소를 대입해 바꿀 수 없다는 한계가 있습니다. 값을 수정해야 하거나 인덱스가 필요하면 일반 for문을 씁니다.
얕은 복사(Shallow Copy)
int[] dest = src;는 값을 복사한 게 아니라 주소값만 전달한 것입니다. 그래서 dest[0] = 99;를 하면 src[0]도 99가 됩니다. 두 변수가 같은 배열 객체를 가리키고 있기 때문입니다. 배열이 참조타입이라서 생기는 자연스러운 결과입니다.
깊은 복사(Deep Copy)
새 객체를 만들어 값을 하나씩 옮기면 독립적인 복사본이 되어 한쪽을 바꿔도 다른 쪽에 영향이 없습니다. 가장 간단한 방법은 System.arraycopy(원본, 시작인덱스, 대상, 시작인덱스, 길이)이고, Arrays.copyOf()나 배열의 clone()도 같은 효과를 냅니다.
주의: 객체 배열은 arraycopy로도 얕은 복사
기본형 배열(int, double…)은 값이 복사되므로 arraycopy만으로 완전히 독립적입니다. 하지만 객체 배열은 각 칸에 들어 있는 것이 "객체의 주소"라서, arraycopy를 해도 두 배열이 같은 객체들을 가리킵니다. 진짜 깊은 복사를 하려면 ① 각 객체마다 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] ← 원본도 바뀜!
// ---- 깊은 복사: 새 배열에 값을 옮긴다 ----
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().
객체 배열은 arraycopy로도 각 요소가 같은 객체를 가리키므로 진짜 깊은 복사가 아니다.
TIP"정렬 전 원본을 남겨두고 싶다"는 상황이 얕은 복사 버그의 단골입니다. int[] backup = arr; 후 Arrays.sort(arr)를 하면 backup도 정렬되어 버립니다 — arr.clone()을 쓰세요.
18
OOP 3대 개념과 상속 · 메모리 4대 특징
EncapsulationInheritancePolymorphismextends자생부생
한 줄 요약객체지향의 3대 개념은 은닉화(데이터 보호)·상속성(코드 재사용)·다형성(하나의 타입으로 여러 형태 처리)이며, 상속 시 메모리에서는 자식보다 부모가 먼저 만들어지고(자생부생) 부모 타입으로 참조하면 부모에 공개된 메서드만 쓸 수 있다(설공메사).
쉽게 말하면은닉화는 자판기(내부는 안 보이고 버튼으로만 조작), 상속은 가업 물려받기(부모의 기술을 그대로 쓰면서 내 것도 추가), 다형성은 "동물"이라고 부르면 개는 멍멍, 고양이는 야옹 하는 것이에요. 그리고 자식이 태어나려면 부모가 먼저 있어야 한다(자생부생)는 게 메모리에서도 그대로 적용됩니다.
① 은닉화(Encapsulation)
데이터 보호를 위해 멤버필드를 private으로 막고, 메서드를 통해서만 접근하게 하는 것입니다. 클래스 B가 클래스 A의 private 필드에 직접 접근하는 길을 막고 public 메서드라는 창구만 열어 둡니다. 패키지나 jar(아카이브)로 묶는 것도 더 큰 범위의 은닉화입니다.
② 상속성(Inheritance)
하위 클래스가 상위 클래스에서 정의한 속성과 메서드를 그대로 사용할 수 있게 하는 것으로 extends 키워드를 씁니다(Generalization, 일반화라고도 부릅니다). 부모를 가리킬 땐 super, 자신은 this를 씁니다. 단 오버라이딩되지 않는 것이 셋 있습니다 — ① static 메서드 ② 부모의 생성자(아예 물려받지 못함) ③ 부모의 private 메서드.
③ 다형성(Polymorphism)
다양한 형태를 나타낼 수 있는 능력으로, 전제 조건은 상속 + 오버라이딩입니다. 발생 원리 3가지를 수업에서는 줄임말로 외웁니다 — 부타자생(부모 타입으로 자식을 생성: Parent p = new Child();), 부타자참(부모 이름으로 자식을 참조: Parent p2 = child;, 다시 자식 기능을 쓰려면 (Child)p2로 캐스팅), 부메자호(부모의 메서드를 호출하면 실제로는 자식의 메서드가 실행됨 = VMI, Virtual Method Invocation).
메모리 4대 특징 (줄임말로 외우기)
자생부생: 자식이 생성되려면 부모가 먼저 생성되어야 한다(heap). 부모를 초기화하지 못하는 객체는 생성될 수 없습니다. 자설부설: 자식의 설계도가 올라가면 부모 설계도도 같이 올라간다(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();
}
}
오버라이딩 안 되는 것 3가지: static 메서드, 부모 생성자, 부모의 private 메서드.
다형성 3원리: 부타자생 · 부타자참 · 부메자호(VMI).
메모리 4대 특징: 자생부생 · 자설부설 · 생주부주 · 설공메사.
부모 타입으로 참조하면 부모에 선언된 멤버만 쓸 수 있고, 자식 고유 기능은 캐스팅이 필요하다.
TIP자바는 다중 상속을 지원하지 않습니다(클래스는 하나만 extends 가능). 여러 특성을 조합해야 한다면 인터페이스를 여러 개 implements하는 방식으로 해결합니다.
19
오버라이딩(Override)과 VMI
@Override재정의VMIMethod Area
한 줄 요약오버라이딩은 부모가 이미 가진 메서드를 자식이 같은 형태로 다시 정의하는 것이며, 부모 타입으로 호출해도 실행 시점에 실제 객체(자식)의 메서드가 불리는 것이 VMI(Virtual Method Invocation)다.
쉽게 말하면오버라이딩은 물려받은 가게 메뉴를 내 방식으로 바꾸는 것이에요. 간판(메서드 이름·매개변수)은 그대로 두고 레시피만 바꿉니다. 손님이 "부모 가게 간판"을 보고 주문해도, 실제로 요리하는 사람이 자식이면 자식의 레시피가 나옵니다 — 이게 VMI입니다.
오버라이딩의 조건
① 상속 관계가 전제되어야 합니다. ② 오버라이드할 메서드를 부모가 반드시 가지고 있어야 합니다. ③ 부모와 같은 형태(이름·매개변수·리턴타입)여야 합니다. ④ 접근 제한자는 부모보다 좁아질 수 없습니다(public을 private으로 바꾸는 것은 불가). @Override 어노테이션은 필수는 아니지만, 오타로 인해 오버라이딩이 아니라 새 메서드를 만들어 버리는 실수를 컴파일러가 잡아 주므로 항상 붙이는 것이 좋습니다.
왜 재정의하는가
하위 클래스에서 메서드의 역할을 변경하거나 확장할 필요가 있을 때 씁니다. 공통적인 요소는 상위로 끌어올리고, 하위마다 내용이 달라지는 부분만 재정의하는 것이 객체지향 설계의 기본 흐름입니다. 🧪 마방진 과제에서 MagicSquare의 make()를 홀수/짝수/6마방진이 각각 다르게 재정의하는 구조가 정확히 이 형태입니다.
VMI(Virtual Method Invocation)의 메모리 동작
Method Area에는 부모 클래스 A의 make()와 자식 클래스 B의 make()가 둘 다 올라가 있습니다. A obj = new B();로 만든 객체의 make()를 호출하면, 컴파일 시점에는 A의 것을 참조하지만 실행 시점에 실제 객체가 B이므로 B의 make()로 연결됩니다. 이 "실행 시점에 결정되는 연결"이 VMI이며, 다형성이 실제로 동작하는 원리입니다.
Method Area가 담는 것
Method Area는 흔히 "static 메모리"라고도 불리지만 더 넓은 개념으로, 클래스의 정보, static 필드·메서드, 상수 풀을 저장합니다. 클래스 설계도가 여기에 올라가 있기 때문에 "설계도에 공개된 메서드만 사용 가능"(설공메사)이라는 규칙이 성립합니다.
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.메서드()를 먼저 호출한다.
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();를 자동으로 넣어 줍니다.
부모와 자식이 같은 이름의 필드를 가질 때
아래 Mother/Sun 예제처럼 둘 다 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 Sun extends Mother {
private String name; // 부모와 같은 이름의 필드 — 둘 다 존재한다
public Sun(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 Sun("mom", "sun").display();
// ---- 참조타입 캐스팅: instanceof 로 확인 후 변환하는 것이 안전 ----
Mother m = new Sun("mom", "sun");
if (m instanceof Sun) {
Sun s = (Sun) m;
s.display();
}
}
}
핵심 정리
this.멤버=자기 것, super.멤버=부모 것 / this()=내 다른 생성자, super()=부모 생성자.
this()·super()는 생성자의 첫 줄에만 올 수 있어 한 생성자에서 둘 다 쓸 수 없다.
필드는 오버라이딩되지 않고 부모·자식 것이 모두 존재한다.
부모 → 자식 캐스팅은 명시적으로 해야 하며 instanceof로 먼저 확인하는 것이 안전하다.
TIP생성자에서 this.name = name;의 this.를 빠뜨리면 매개변수에 매개변수를 대입하는 꼴이 되어 필드는 계속 null인 채로 남습니다. 컴파일 에러도 안 나서 찾기 어려운 대표적 실수입니다.
21
추상 클래스(Abstract)
abstract추상메서드상속강요객체생성금지
한 줄 요약abstract 메서드는 몸체 없이 선언만 하고 자식에게 구현을 강요하며, 추상 메서드가 하나라도 있는 클래스는 반드시 추상 클래스가 되고 new로 객체를 만들 수 없다.
쉽게 말하면추상 클래스는 빈칸이 있는 계약서예요. "코끼리는 먹는다(eat)"는 항목은 있지만 무엇을 어떻게 먹는지는 비워 둔 상태라서, 아시아코끼리·아프리카코끼리가 계약서를 물려받으면 그 빈칸을 반드시 채워야 합니다. 빈칸이 남아 있는 계약서 자체로는 계약(객체 생성)을 할 수 없습니다.
abstract의 규칙
① abstract 예약어로 선언합니다. ② 객체 생성 금지 — Parent p = new Parent();는 불가능합니다. ③ 상속 강요 — 상속받는 곳에서 추상 메서드를 반드시 구현(오버라이딩)해야 합니다. ④ 클래스 안에 abstract 메서드가 하나라도 있으면 그 클래스는 반드시 abstract class여야 합니다. ⑤ 반대로 추상 메서드가 없어도, 객체 생성을 막고 상속을 강요할 목적으로 abstract를 붙일 수 있습니다.
추상 클래스는 일반 멤버도 가질 수 있다
추상 클래스는 필드와 구현된 메서드, 생성자까지 가질 수 있습니다. 즉 "공통으로 쓸 부분은 미리 구현해 두고, 하위마다 달라지는 부분만 추상 메서드로 비워 두는" 구조를 만들 수 있습니다. 이것이 인터페이스와의 가장 큰 차이점입니다(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로 객체를 만들 수 없고(상속 전용), 상속받은 쪽에 구현을 강요한다.
필드·생성자·구현된 메서드를 모두 가질 수 있어 공통 코드를 물려줄 수 있다.
추상 타입으로 참조하고 자식 구현을 실행하는 다형성 패턴이 핵심 용도다.
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();처럼 몸체 없이 이름만 나열합니다. ③ private는 쓸 수 없고 public 또는 protected를 씁니다. ④ 구현하지 않으면 그 클래스는 추상 클래스가 되어야 합니다. ⑤ 상속과 함께 쓸 때는 extends를 먼저 쓰고 implements를 나중에 씁니다. ⑥ 인터페이스는 여러 개를 동시에 구현할 수 있습니다.
JDK 8 이후 추가된 것들
인터페이스를 수정하면 그것을 구현한 모든 기존 클래스가 깨지는 문제 때문에, 호환성을 유지하면서 유연하게 설계할 수 있도록 다음이 추가되었습니다 — default 메서드(인터페이스 안에서 기본 구현을 제공, 구현 클래스가 굳이 재정의하지 않아도 됨), static 메서드(정적 유틸 메서드 정의), 함수형 인터페이스(추상 메서드가 딱 하나여서 람다식으로 구현 가능), @FunctionalInterface 어노테이션(함수형 인터페이스임을 명시하고 컴파일러가 검사).
선언과 구현의 분리
인터페이스는 메서드 목록만 나열하고 실제 구현은 자식 쪽에서 합니다. 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
default void float_() { // JDK 8+: 기본 구현 제공 (재정의 안 해도 됨)
System.out.println("물에 뜬다");
}
static String info() { // JDK 8+: static 메서드
return "수영 가능 인터페이스";
}
}
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).
추상 클래스 = is-a + 상태/구현 공유, 인터페이스 = can-do + 동작 명세 + 다중 구현.
TIP🧪 마방진 과제의 클래스 다이어그램이 이 둘을 함께 쓰는 좋은 예입니다 — MagicSquare(추상 클래스, 공통 필드 magic[][]과 합 검증 로직 공유) + Interface_Magic(make/print 명세) + MagicFactory(싱글턴 팩토리)의 조합입니다.
23
중첩 클래스(Nested Class)
static 중첩내부 클래스지역 클래스익명 클래스
한 줄 요약클래스 안에 정의하는 클래스로, static 중첩(독립적)·인스턴스 내부(외부 객체에 종속)·지역(메서드 안)·익명(이름 없이 즉시 생성) 네 종류가 있고 종류에 따라 외부 멤버 접근 범위가 다르다.
쉽게 말하면중첩 클래스는 큰 서랍 안의 작은 칸이에요. 그 클래스 안에서만 쓰이는 보조 클래스를 밖에 따로 두면 파일만 늘어나니, 쓰이는 자리 바로 옆에 두는 겁니다. 익명 클래스는 특히 일회용 종이컵 같아서, 이름도 안 붙이고 한 번 쓰고 버립니다.
왜 쓰는가
논리적으로 연관된 클래스들을 그룹화해 코드의 가독성을 높이고 캡슐화를 강화합니다. 외부에서는 쓸 일이 없고 특정 클래스 안에서만 의미가 있는 보조 클래스를 만들 때 적합합니다.
4가지 종류와 접근 범위
정적 중첩 클래스(static class C {}): 독립적으로 사용 가능하며 외부의 static 멤버만 접근할 수 있습니다. 유틸리티 역할에 적합합니다. 인스턴스 내부 클래스(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();
}
}
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: 학생 명단(순서대로, 동명이인 허용), 게시글 목록(최신순), 검색어 기록(시간순, 중복 허용), 장바구니(같은 상품 여러 개), 재생 목록, 채팅 메시지. 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, 중복 X ----
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는 인덱스 없는 자료구조를 순회하고 안전하게 삭제하는 공통 도구다.
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는 비동기(asynchronous)라 여러 스레드가 동시에 접근할 수 있고 속도가 빠릅니다. 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 = 연결 리스트, 그 반대.
TIPlist.remove(1)과 list.remove("1")은 완전히 다릅니다 — 앞은 인덱스로, 뒤는 값으로 삭제합니다. List<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() + " ");
}
}
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}
}
}
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는 에러 여부와 관계없이 무조건 실행되므로, 파일이나 네트워크 연결을 닫는 것처럼 반드시 해야 하는 뒷정리에 씁니다.
Multi catch — 하위 예외를 먼저
catch 블록은 여러 개 쓸 수 있는데, 상위(포괄적) 예외를 나중에 써야 합니다. catch (Exception e)를 맨 위에 두면 모든 예외가 거기서 걸려 아래 catch는 도달할 수 없게 되어 컴파일 에러가 납니다. Java 7부터는 catch (A | B e)처럼 한 줄로 묶을 수도 있습니다.
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 가능.
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 등)으로 나뉩니다.
3대 Checked Exception
java.io, java.net, java.sql 패키지의 작업은 반드시 예외 처리를 해야 컴파일됩니다. 파일이 없을 수도, 네트워크가 끊길 수도, 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());
}
}
}
3대 Checked Exception 영역: java.io, java.net, java.sql.
throw는 예외를 던지는 행위, throws는 던질 수 있다는 선언.
사용자 정의 예외는 Exception을 상속하고 super(message)로 메시지를 전달한다.
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 등이 있습니다.
장점과 단점
장점: 불필요한 익명 클래스 제거로 코드 간결화, 핵심 로직만 남아 가독성 향상, 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는 추상 메서드가 하나인지 컴파일러가 검사하게 한다.
간결하지만 디버깅이 어렵고 재사용성이 낮아, 짧은 로직에 쓰는 것이 적합하다.
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(). 실무에서는 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 (합계)
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()으로 병렬 처리도 가능하다.
TIP반복문으로 20줄 걸리던 "필터링 → 변환 → 정렬 → 수집"이 Stream으로는 4줄이 됩니다. 다만 디버깅이 어렵고 단순 반복에는 오히려 느릴 수 있으므로, 데이터 가공이 여러 단계일 때 쓰는 것이 좋습니다.
09
입출력(IO) 개요 — 스트림 · 노드 · 필터
StreamNodeFilterByteChar
한 줄 요약자바의 IO 스트림은 한 방향으로만 흐르는 데이터 통로로, 1byte 단위 처리(InputStream/OutputStream)와 2byte 문자 단위 처리(Reader/Writer)로 나뉘고, 실제 소스에 직접 연결되면 노드 스트림, 다른 스트림을 감싸 기능을 더하면 필터 스트림이다.
쉽게 말하면스트림은 물이 흐르는 파이프예요. 파이프는 한 방향으로만 흐르고 서로 섞이지 않으며, 읽는 파이프(Input/Read)와 쓰는 파이프(Output/Write)가 따로 있습니다. 노드 스트림은 수도꼭지에 직접 연결된 파이프이고, 필터 스트림은 그 파이프에 끼우는 정수 필터라서 혼자서는 아무 데도 연결되지 않습니다.
스트림의 성질
① 연속적인 데이터의 흐름이며 전송 단위는 byte입니다. ② 단방향으로 흐릅니다(읽기 전용 또는 쓰기 전용). ③ 스트림끼리는 섞이지 않습니다. ④ 버퍼를 가질 수 있고, 데이터를 조작하려면 버퍼를 써야 합니다. ⑤ 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(); // 감싼 쪽(마지막에 만든 것)부터 닫는다
// ---- 키보드 입력도 같은 구조 ----
// 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, 데이터 조작에는 버퍼가 필요하다.
1byte 단위 = InputStream/OutputStream(바이너리) / 2byte 단위 = Reader/Writer(문자).
이름에 IN·READ = 읽기, OUT·WRITE = 쓰기.
노드 스트림(소스에 직접 연결) vs 필터 스트림(다른 스트림을 감싸 기능 추가).
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인 이유는 byte 범위(-128~127)에서 음수도 양수로 표현하기 위해서이며, 음수(-1)가 반환되면 더 읽을 데이터가 없다는 뜻입니다. read()는 읽은 값을, read(byte[])는 읽은 데이터 수를 반환합니다. read()는 추상 메서드인데, 읽어들이는 대상(파일·네트워크·메모리)에 맞게 각각 구현하기 위해서입니다.
쓰기: write()와 flush()
write(int)·write(byte[])·write(byte[], index, length)가 있고, Writer 계열에는 write(String)도 있습니다. 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 단위 읽기: 반환값이 음수(-1)면 더 읽을 데이터가 없다는 뜻 ----
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()의 반환 타입이 int인 이유는 음수 표현 때문이며, 음수(-1)면 데이터 끝이다.
flush()는 버퍼를 강제로 비우고, close()를 호출하면 자동 flush된다.
Reader에는 문자열 읽기가 없어 BufferedReader.readLine()을 쓴다.
close()는 finally(또는 try-with-resources)에서, 가장 바깥 스트림부터 닫는다.
TIP"파일을 만들었는데 내용이 비어 있다"면 십중팔구 close()를 안 한 것입니다. 버퍼에만 쌓여 있고 실제 파일에는 안 쓰인 상태이기 때문입니다.
11
스레드(Thread) — 생성 · 동기화 · 생명주기
ThreadRunnablesynchronizedstart()wait/notify
한 줄 요약스레드는 프로세스 안에서 동시에 실행되는 작업 단위로 Runnable 구현 또는 Thread 상속으로 만들고 start()로 실행하며, 공유 객체를 여러 스레드가 건드릴 때는 synchronized로 동기화해야 한다.
쉽게 말하면프로세스가 식당 하나라면 스레드는 그 안의 직원들이에요. 직원이 여럿이면 주문·요리·서빙을 동시에 할 수 있지만, 같은 냄비(공유 객체)를 두 명이 동시에 건드리면 요리가 엉망이 됩니다. synchronized는 "이 냄비 쓰는 동안 아무도 손대지 마"라고 잠그는 것입니다.
프로세스 vs 스레드
멀티 프로세스는 애플리케이션 단위의 멀티태스킹이고, 멀티 스레드는 하나의 애플리케이션 안에서 여러 작업을 동시에 실행하는 것입니다. 스레드는 프로세스 내부의 작은 실행 단위로, 같은 메모리를 공유하기 때문에 가볍지만 그만큼 공유 자원 충돌에 주의해야 합니다.
생성 방법 2가지와 장단점
① Runnable 인터페이스 구현: Thread tr = new Thread(myRunnable); tr.start(); — 장점은 다른 클래스를 이미 상속받고 있어도 쓸 수 있고(자바는 단일 상속), 스레드와 작업을 분리해 재사용성·유지보수성이 좋다는 것. 단점은 Thread의 메서드를 직접 오버라이드할 수 없어 세밀한 제어가 어렵다는 것. ② Thread 클래스 상속: Thread tr = new MyThread(); tr.start(); — 장점은 코드가 간결하고 스레드 메서드를 오버라이드해 세밀하게 제어할 수 있다는 것, 단점은 다른 클래스를 상속할 수 없고 재사용성이 떨어진다는 것.
start()와 run()의 차이
run()을 직접 호출하면 그냥 메서드 호출이라 현재 스레드에서 순차 실행됩니다. 반드시 start()를 호출해야 JVM이 새 스레드를 만들어 그 안에서 run()을 실행합니다. 초보자가 가장 많이 하는 실수입니다.
동기화(synchronized)
여러 스레드가 같은 객체를 공유하면, 스레드1이 값을 저장하고 출력하기 전에 스레드2가 값을 바꿔 버려 둘 다 같은(잘못된) 값을 출력하는 문제가 생깁니다. synchronized 메서드(메서드 전체 잠금)나 synchronized 블록(특정 객체에 대한 잠금)을 쓰면, 현재 스레드가 끝나기 전까지 다른 스레드는 그 영역을 실행할 수 없습니다.
생명주기와 주요 메서드
상태는 실행대기 ↔ 실행 → 일시정지 → 종료로 오갑니다. start()(시작) · run()(작업 정의) · join()(다른 스레드가 끝날 때까지 대기) · sleep()(일정 시간 정지) · interrupt()(인터럽트) · yield()(같은 우선순위에 양보) · setPriority()(우선순위) · setDaemon()(데몬 스레드) · currentThread()(현재 스레드 조회). 그리고 wait()(notify가 부를 때까지 정지) · notify()/notifyAll()(대기 중인 스레드를 깨움)은 반드시 synchronized 상태에서만 사용할 수 있습니다.
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 상속(간결·세밀한 제어).
반드시 start()로 실행 — run()을 직접 부르면 새 스레드가 만들어지지 않는다.
공유 객체 접근은 synchronized 메서드/블록으로 보호한다.
wait()·notify()·notifyAll()은 synchronized 안에서만 사용 가능하다.
join()은 그 스레드가 끝날 때까지 현재 스레드를 대기시킨다.
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단계로 동작합니다. 데이터의 도착과 순서가 보장되며, 일반적인 HTTP 통신도 TCP 방식을 따릅니다.
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의 기반.
TIP"신뢰성"은 속도가 아니라 보장의 문제입니다. TCP도 충분히 빠르며, UDP를 쓰는 이유는 "조금 손실돼도 되니 지연이 없어야 하는" 상황(실시간 통화에서 0.5초 늦게 도착한 음성은 이미 쓸모없음) 때문입니다.
02
유니캐스팅 · 브로드캐스팅 · 멀티캐스팅
UnicastBroadcastMulticastMAC
한 줄 요약유니캐스팅은 특정 장치 하나와의 1:1 통신, 브로드캐스팅은 네트워크 내 모든 호스트에게 보내는 1:N 통신, 멀티캐스팅은 특정 그룹에게만 보내는 방식이다.
쉽게 말하면유니캐스팅은 한 사람에게 전화 걸기, 브로드캐스팅은 동네 방송으로 전체 안내, 멀티캐스팅은 단톡방에만 공지예요. 같은 내용을 100명에게 보낼 때 전화를 100번 거는 건 비효율적이고, 동네 방송은 관심 없는 사람까지 듣게 되니, 그 중간이 단톡방(멀티캐스팅)입니다.
유니캐스팅
두 장치 간의 1:1 통신으로, 특정 장치의 물리주소(MAC, 네트워크 카드 고유값)를 지정해 통신합니다. 가장 일반적인 방식이지만, 같은 내용을 여러 사용자에게 보낼 때는 호스트마다 데이터를 반복 전송해야 하므로 비효율적입니다.
브로드캐스팅
UDP 기반의 통신 방식으로 자신이 속한 네트워크 내 모든 호스트에게 데이터를 전달하는 1:N 방식입니다. 수신지 주소에 브로드캐스트 주소를 사용합니다. 내부의 모든 호스트에게 같은 데이터를 보낼 때는 효율적이지만, 필요 없는 호스트까지 트래픽을 받게 됩니다.
멀티캐스팅
특정 그룹에게만 데이터를 전달하는 방식입니다. 유니캐스팅은 같은 데이터를 여러 번 반복 전송해야 하고 브로드캐스팅은 원하지 않는 그룹에도 전달되는데, 멀티캐스팅은 그 둘의 단점을 모두 피합니다. IPv4에서 클래스 D(224.0.0.0 ~ 239.255.255.255)가 멀티캐스트용으로 예약되어 있습니다.
핵심 정리
유니캐스팅 = 1:1, MAC 주소 지정, 다수 전송 시 반복이라 비효율.
브로드캐스팅 = UDP 기반 1:N, 네트워크 내 모든 호스트, 브로드캐스트 주소 사용.
멀티캐스팅 = 특정 그룹에만 전송, 유니/브로드캐스팅의 단점을 보완.
IPv4 클래스 D(224~239)가 멀티캐스트 전용 대역이다.
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(향후 사용을 위한 예약). 앞쪽 옥텟이 NetID로 쓰일수록 네트워크 수는 많아지고 각 네트워크의 호스트 수는 줄어듭니다.
서브넷 마스크
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가 동적 할당을 관리한다.
TIP소켓 예제에서 localhost(= 127.0.0.1)를 쓰는 이유는 내 컴퓨터 자신을 가리키는 루프백 주소이기 때문입니다. 같은 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, 물리 계층)로 전송됩니다. 받는 쪽에서는 반대로 헤더를 하나씩 벗겨내며 올라갑니다.
자바 프로그래머가 다루는 층
자바의 소켓 프로그래밍은 주로 전송 계층(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 계층을 내려갈수록 헤더가 붙으며 세그먼트 → 패킷 → 프레임 → 비트로 바뀐다.
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()로 내용을 꺼냅니다.
핵심 정리
TCP: Socket(클라이언트) + ServerSocket(서버, accept()는 연결이 올 때까지 블록).
소켓에서 getInputStream()·getOutputStream()을 얻어 IO 스트림처럼 다룬다.
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을 반환하면 상대가 연결을 끊은 것이다.
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)로 하면 뒤에 이전 데이터나 빈 공간이 섞입니다.
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();
packet = new DatagramPacket(buffer, buffer.length, address, port);
socket.send(packet);
}
} 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[] buffer = new byte[512];
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String text;
while (true) {
System.out.print("Enter text: ");
text = reader.readLine();
buffer = text.getBytes(); // 문자열 → byte 배열
// 전송용 패킷: 데이터 + 길이 + 목적지 주소 + 포트
DatagramPacket packet =
new DatagramPacket(buffer, buffer.length, address, port);
socket.send(packet);
// 수신용 패킷: 데이터 + 길이만 (주소 불필요)
packet = new DatagramPacket(buffer, buffer.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())로 실제 길이만큼만 변환한다.
TIPUDP는 순서와 도착을 보장하지 않으므로, 순서가 중요한 데이터를 보내려면 애플리케이션에서 직접 번호를 붙이고 재전송 로직을 만들어야 합니다. 그럴 바에는 TCP를 쓰는 게 나은 경우가 대부분입니다.
08
멀티스레드 서버와 채팅 프로그램
멀티스레드accept 루프작업 스레드채팅
한 줄 요약메인 스레드는 accept()로 연결만 받고 각 클라이언트의 요청 처리는 별도의 작업 스레드에 위임하면, 여러 클라이언트를 동시에 처리하는 서버를 만들 수 있다.
쉽게 말하면단일 스레드 서버는 직원 한 명인 식당이에요. 첫 손님 응대가 끝날 때까지 두 번째 손님은 문밖에서 기다립니다. 멀티스레드 서버는 손님이 올 때마다 담당 직원을 한 명씩 붙여 주는 것이라, 여러 손님이 동시에 식사할 수 있습니다.
왜 멀티스레드가 필요한가
서버가 한 클라이언트와 readLine()으로 통신하는 동안에는 accept()로 돌아갈 수 없습니다. 그러면 두 번째 클라이언트는 첫 번째가 접속을 끊을 때까지 기다려야 합니다. 각 클라이언트의 요청을 별도 스레드에서 처리하면 응답 속도가 빨라지고 동시에 여러 클라이언트를 받을 수 있습니다.
서버의 2층 구조
메인 서버 스레드는 클라이언트의 연결 요청을 수신하고, 각 요청을 처리할 새 스레드를 생성해 작업을 위임하는 일만 합니다. 작업 스레드는 개별 클라이언트의 요청을 처리하고 응답을 만들어 전송합니다. 이 분리 덕분에 메인 스레드는 항상 accept()로 되돌아가 대기할 수 있습니다.
멀티스레드 서버의 이점
성능 향상(여러 요청을 병렬 처리) · 응답 시간 감소(대기 시간 축소) · 확장성(클라이언트가 늘어도 효율적 처리) · 실시간 응답(온라인 게임·실시간 스트리밍처럼 즉각적인 응답이 필요한 애플리케이션에 필수).
채팅 프로그램으로 확장하기
에코 서버(받은 걸 그대로 돌려줌)를 채팅 서버로 바꾸려면 접속한 모든 클라이언트의 출력 스트림을 컬렉션에 보관했다가, 한 명이 메시지를 보내면 전체에게 다시 뿌리면(broadcast) 됩니다. 이때 여러 스레드가 같은 목록을 동시에 건드리므로 동기화가 필요하고, 목록 자료구조로는 Collections.synchronizedList()나 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 순회로 보호한다.
TIP실무에서는 접속마다 스레드를 새로 만드는 대신 스레드 풀(ExecutorService)을 씁니다. 접속자가 수천 명이면 스레드 생성 비용과 메모리가 감당이 안 되기 때문입니다 — Executors.newFixedThreadPool(10)처럼 미리 만들어 둔 스레드를 재사용합니다.
🧪 5. 자바 실습과제 12문제
「자바 실습문제」 과제 12문항의 접근 방법과 풀이 단계, 핵심 코드를 정리했습니다. 앞쪽(1~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번 완전수에서 계속 필요합니다. 처음부터 getDivisorSum(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 getProperDivisorSum(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(getProperDivisorSum(220)); // 284
}
}
핵심 정리
"나누어 떨어진다" = num % i == 0.
약수는 1부터 자기 자신까지 반복하며 찾는다.
진약수(자기 자신 제외)는 i < num까지만 반복한다.
약수 합 구하기를 메서드로 분리하면 4·5번 문제에서 그대로 재사용된다.
TIP성능을 생각한다면 i * i <= num까지만 돌면서 i와 num/i를 짝으로 담는 방법도 있습니다(약수는 항상 짝을 이루므로). 다만 학습 단계에서는 먼저 단순하게 풀고 나중에 개선하는 순서가 좋습니다.
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(반복)과 재귀 두 방식으로 쓸 수 있다.
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로 써서 오버플로를 피한다.
공식을 모르면 큰 수의 배수를 늘려가며 둘 다 나눠떨어지는 첫 수를 찾아도 된다.
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 조건으로 같은 쌍의 중복 출력을 막는다.
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 조건으로 걸러 준다.
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로 얻을 수 있습니다.
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 → … 순으로 진행됩니다. 각 단계는 앞 항을 왼쪽부터 읽으면서 같은 숫자가 연속으로 몇 번 나오는지를 "숫자, 개수" 순서가 아니라 "개수, 숫자"가 아닌 과제 설명대로 "숫자가 몇 개"를 기록합니다 — 예를 들어 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를 쓴다.
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등입니다.
객체 설계의 관점
이 문제의 핵심은 "무엇이 무엇을 가지는가"입니다 — 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 {
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를 "가지는" 포함 관계가 이 문제의 설계 포인트다.
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를 바꿉니다. 이 방식을 LUX 방법이라고 부릅니다.
④ 검증과 클래스 설계
완성 후에는 각 행의 합·각 열의 합·두 대각선의 합을 모두 구해 전부 같으면 true, 하나라도 다르면 false를 반환하는 검증 메서드를 만듭니다. 과제의 클래스 다이어그램은 MagicSquare(추상 클래스: magic 배열 + 합 검증 메서드들) ← OddMagicSquare·EvenMagicSquare·SixMagicSquare가 make()를 각각 구현하고, Interface_Magic(make·print 명세)과 MagicFactory(싱글턴 팩토리)가 어떤 구현체를 만들지 결정하는 구조입니다.
풀이 단계
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()라는 같은 이름을 갖도록 추상 클래스로 묶는 것이 설계 목표다.
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'을 쓴다.
TIP디버깅할 때는 정답을 미리 출력해 두고 판정 로직만 확인한 뒤 그 줄을 지우세요. 정답을 모르는 채로 판정 버그를 찾으려면 시간이 몇 배로 걸립니다.
11
달력 구현하기 + 살아온 일수 구하기
경과일요일 계산윤년printf
한 줄 요약1년 1월 1일부터 해당 월 1일까지의 총 경과일을 구해 7로 나눈 나머지로 시작 요일(앞쪽 공백 수)을 정하고, 윤년·평년별 월의 마지막 날 배열로 날짜를 채워 출력한다.
쉽게 말하면달력을 그리려면 딱 두 가지만 알면 됩니다 — "1일이 무슨 요일인가"(앞에 빈칸을 몇 개 둘지)와 "이 달이 며칠까지 있는가". 첫 번째는 아주 먼 과거부터 며칠이 지났는지를 세서 7로 나눈 나머지로, 두 번째는 월별 일수 배열로 구합니다.
① 해당 달 1일의 요일 구하기
1년 1월 1일부터 구하려는 달의 1일까지 경과한 총 일수를 구해 % 7을 하면 요일이 나옵니다. 기준을 맞추기 위해 1년 1월 1일이 무슨 요일인지 정해야 하는데, 나머지가 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 — 0이면 일요일이 되도록 맞춘다.
월별 마지막 날은 윤년/평년 배열 두 개로 관리한다.
출력은 시작 요일만큼 공백 → 날짜 출력 → 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 클래스
필요한 것은 ① 무늬를 저장할 배열(♥ ♣ ♠ ◆) ② 숫자를 저장할 배열("2"~"9", "T", "J", "Q", "K"…) ③ 랜덤 숫자를 생성하는 기능입니다. 무늬 인덱스와 숫자 인덱스를 각각 랜덤으로 골라 조합하면 카드 한 장이 됩니다. 무늬·숫자 배열은 모든 카드가 공유하므로 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 =
{"2","3","4","5","6","7","8","9","10","T","J","Q","K"};
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();
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)으로 "한 장 뽑기"를 자연스럽게 표현할 수 있다.
TIP이 문제까지 오면 8번(로또) → 12번(카드)이 같은 설계 패턴이라는 게 보입니다 — "하나(Lotto/Card)"와 "여럿을 담는 것(LottoStore/CardDeck)"을 분리하는 구조입니다. 이 감각이 이후 실무의 Entity/Repository 구조로 이어집니다.
🧭 학습 여정 — 개념이 쌓이는 순서
교안 54개 항목을 "왜 이 순서로 배우는가"라는 하나의 흐름으로 엮은 지도입니다. 📅 수업 진도 탭이 날짜별 기록이고 과목 탭이 항목별 사전이라면, 이 탭은 앞에서 배운 것이 뒤에서 어떻게 다시 쓰이는지를 이어서 보여 줍니다.
💡 이 탭을 보는 법
단계마다 이전 단계에서 배운 것을 명시적으로 언급하며 이어집니다 — "값을 담는 법"에서 시작해 "흐름을 제어하는 법", "코드에 이름을 붙이는 법", "설계도와 실체", "물려받고 바꿔 쓰는 법" 순으로 넓어져요. 상자는 개념 하나, 화살표는 "그걸 가지고 다음에 무엇을 하는지"입니다. 각 단계의 전체 카드 보기를 누르면 해당 과목 탭의 자세한 설명으로 바로 이동합니다. 지금 수업은 07단계 근처(2026-08-06 기준 4일차)를 지나고 있습니다.
문법보다 먼저 배우는 것이 "내가 쓴 글자가 어떻게 프로그램이 되는가"입니다. .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)
📅 지금 수업이 여기입니다 — 2026-08-06(4일차)까지 06~07단계를 지났습니다. 클래스·객체·생성자 오버로딩·this()/super()·private과 getter/setter·인스턴스 vs static, 그리고 Object의 4대 메서드까지가 오늘까지의 범위예요. 시험 대비 탭의 📅 배운 데까지 범위가 딱 이 지점까지를 출제합니다.
여기서 잡는 감각
접근 범위 넓은 순서 — 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단계 그대로
-
여러 명이면
멀티스레드 서버접속마다 스레드 하나
여기서 잡는 감각
TCP는 연결을 맺고 보내 신뢰성이 높고, UDP는 맺지 않고 던져 빠릅니다.
서버는 accept()에서 접속이 올 때까지 멈춰 기다립니다.
여기까지 오면 자바 교안 전 범위가 한 바퀴 — 실습과제 12문제로 종합 점검하세요. → 🧪 실습과제 탭
📖 개념 사전 — 자바 용어와 에러 메시지
수업 진도와 상관없이 궁금한 용어 하나만 찾아보는 사전입니다. 앞의 과목 탭이 "교안을 순서대로 정리한 노트"라면, 여기는 "그 용어가 정확히 무슨 뜻이냐"에만 답합니다. 특히 맨 아래 그룹 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 파일 하나를 만듭니다. 그래서 java HelloJava라고 쳤을 때 JVM은 HelloJava.class를 찾아 그 안의 main을 부릅니다. 파일명과 public 클래스명이 다르면 이 약속이 깨지므로 컴파일 단계에서 막습니다. package 선언이 폴더 구조와 일치해야 하는 것도 같은 이유 — 이름이 곧 찾아가는 경로이기 때문입니다.
파일명 = public 클래스명, package 선언 = 폴더 경로. 둘 다 "이름으로 찾아가기 때문"에 강제된다.
02
메모리 3영역 — Method Area · Stack · Heap
static 영역스택힙지역변수
한 줄 요약자바가 값을 두는 자리는 셋뿐이다 — Method Area(static·클래스 정보, 프로그램 내내 하나), Stack(메서드 안 지역변수·매개변수, 메서드가 끝나면 사라짐), Heap(new로 만든 객체, 아무도 안 쓰면 나중에 치워짐).
쉽게 말하면사무실로 비유하면 Method Area는 벽에 붙은 공지사항(모두가 보고, 안 바뀌고, 하나뿐), 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은 객체 없이도 이미 메모리에 있으므로 클래스명.멤버로 접근한다.
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 = 아직 아무 객체도 가리키지 않는 상태.
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이 붙들고 있는 객체는 회수되지 않는다.
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를 해야 생긴다.
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.로 지목한다.
07
생성자(Constructor) — 객체가 태어나는 순간 딱 한 번
생성자기본 생성자오버로딩this()
한 줄 요약생성자는 클래스명과 이름이 같고 반환타입이 없는 특수한 메서드로, new하는 순간 딱 한 번 실행돼 객체의 초기 상태를 정한다. 매개변수가 있는 생성자를 하나라도 직접 만들면 기본 생성자는 더 이상 자동으로 생기지 않는다.
쉽게 말하면붕어빵을 굽는 순간 "팥이요, 슈크림이요"를 정하는 주문서입니다. 아무 말 없이 주문하면 기본 맛(기본 생성자), 크기만 말하면 크기만 바꿔서, 크기와 맛을 다 말하면 둘 다 바꿔서 굽습니다 — 이렇게 주문서 양식을 여러 개 만들어 두는 것이 생성자 오버로딩입니다.
세 가지 규칙
이름은 클래스명과 같고, 반환타입을 쓰지 않는다.public void D4_Constructor()처럼 void를 붙이면 그건 생성자가 아니라 이름이 우연히 같은 일반 메서드가 됩니다 — 찾기 어려운 실수 1순위입니다.
직접 만든 생성자가 하나라도 있으면 기본 생성자는 자동 생성되지 않는다. 그래서 new D2_ClassTest()도 쓰고 싶다면 인자 없는 생성자를 명시적으로 선언해야 합니다.
첫 줄에는 this(...)나 super(...) 중 하나만 올 수 있습니다(둘 다 "첫 줄" 자리를 요구하므로 함께 쓸 수 없음). 아무것도 안 쓰면 컴파일러가 super()를 넣어 줍니다.
한 줄 요약캡슐화는 필드를 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로 물려받아 확장. 자바는 클래스 다중 상속을 지원하지 않는다.
다형성 = 부모 타입으로 자식들을 똑같이 다루는 것 — 오버라이딩이 있어야 성립한다.
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)까지 따라오지는 않습니다.
한 줄 요약==는 주소가 같은가(같은 객체인가)를 보고, 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()도 같이 재정의한다.
12
오버로딩 vs 오버라이딩 — 이름만 비슷한 완전히 다른 것
오버로딩오버라이딩@Override다형성
한 줄 요약오버로딩(Overloading)은 한 클래스 안에서 같은 이름의 메서드를 매개변수만 다르게 여러 개 만드는 것이고, 오버라이딩(Overriding)은 부모에게 물려받은 메서드를 자식이 똑같은 모양으로 다시 쓰는 것이다.
쉽게 말하면오버로딩은 같은 이름의 주문 메뉴가 여러 개인 것 — "아메리카노(톨)", "아메리카노(그란데)"처럼 주문 방식만 다른 형제 메뉴입니다. 오버라이딩은 물려받은 레시피를 내 가게 방식으로 덮어쓰는 것 — 메뉴 이름은 그대로인데 만드는 법이 바뀝니다.
구분하는 기준 딱 하나 — "어디에 있는가"
오버로딩 — 같은 클래스 안. 매개변수의 개수·타입·순서가 달라야 하고, 반환타입만 다른 건 오버로딩이 아닙니다(컴파일 에러). 생성자 오버로딩도 같은 규칙입니다.
오버라이딩 — 부모-자식 관계. 메서드 이름·매개변수·반환타입이 모두 같아야 하고, 접근 범위를 부모보다 좁게 만들 수 없습니다(public을 private으로 못 바꿈).
@Override는 없어도 동작하지만 붙이는 게 정답입니다 — 오타로 이름이 살짝 달라지면 "재정의가 아니라 새 메서드"가 조용히 만들어지는데, @Override가 있으면 컴파일러가 즉시 잡아 줍니다. → 🧱 06. 오버로딩 · 🧱 19. 오버라이딩과 VMI
핵심 정리
오버로딩 = 같은 클래스, 매개변수를 다르게. 반환타입만 다른 건 안 된다.
오버라이딩 = 부모의 메서드를 똑같은 모양으로 자식이 다시 정의.
오버라이딩은 접근 범위를 좁힐 수 없다.
다형성이 성립하는 것은 오버라이딩 덕분이다.
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 대상이 되지 않는다.
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이 납니다.
한 줄 요약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(동기화, 느림).
16
배열 vs 컬렉션 — 크기를 미리 정해야 하는가
배열ArrayListlengthsize()
한 줄 요약배열은 만들 때 크기를 정하면 끝까지 못 바꾸고 기본타입도 담을 수 있는 반면, ArrayList 같은 컬렉션은 크기가 자동으로 늘어나고 객체만 담을 수 있으며 추가·삭제 메서드를 제공한다.
쉽게 말하면배열은 칸 수가 정해진 계란판(10칸짜리를 샀으면 11개는 못 담음), 컬렉션은 지퍼백(넣는 대로 늘어남)입니다. 계란판이 더 빠르고 가볍지만, 몇 개가 들어올지 모를 때는 지퍼백이 편하죠.
헷갈리는 이름 세 가지
배열은 arr.length(괄호 없는 필드), 컬렉션은 list.size()(메서드), 문자열은 str.length()(메서드) — 셋이 미묘하게 달라서 시험 단골입니다.
한 줄 요약추상 클래스는 완성된 코드와 미완성 메서드를 섞어 물려주는 부모이고(extends, 하나만), 인터페이스는 "이 기능들은 반드시 만들어라"는 약속 목록이다(implements, 여러 개 가능).
쉽게 말하면추상 클래스는 반쯤 지어진 집을 물려받는 것 — 골조와 배관은 이미 있고 내부 인테리어만 내가 채웁니다. 인터페이스는 계약서예요 — "전원 켜기, 끄기 기능은 반드시 만들 것"이라고 적혀만 있고 내용은 전부 내가 씁니다. 집은 하나만 물려받을 수 있지만 계약서는 여러 장에 서명할 수 있습니다.
고르는 기준
공통 코드를 물려주고 싶다 → 추상 클래스. 필드와 구현된 메서드를 가질 수 있습니다.
서로 상관없는 클래스들에 같은 기능을 강제하고 싶다 → 인터페이스. 상속 계층과 무관하게 붙일 수 있고, 여러 개를 동시에 구현할 수 있습니다(자바가 클래스 다중 상속을 막은 대신 열어 둔 길).
한 줄 요약null인 참조변수에 대고 필드를 읽거나 메서드를 부를 때 나는, 자바에서 가장 흔한 실행 중(runtime) 에러다 — 컴파일은 멀쩡히 되고 실행할 때 터진다는 게 특징이다.
쉽게 말하면택배 송장 번호가 비어 있는데 물건을 열어 보려고 한 상황입니다. 번호가 없으니 찾아갈 곳도 없죠. 그래서 고치는 방향은 늘 둘 중 하나예요 — 번호를 채워 주거나(객체를 만들어 주거나), 번호가 있는지 먼저 확인하거나.
자주 나오는 상황
객체를 new 하지 않고 선언만 한 뒤 바로 사용 — Scanner sc; sc.nextInt();
배열은 만들었지만 안의 객체는 안 만든 경우 — String[] arr = new String[3]; arr[0].length();(칸은 3개지만 전부 null).
메서드가 null을 돌려줬는데 그대로 이어서 쓴 경우 — map.get("없는키").length().
null인 Integer를 int에 대입(언박싱 순간 터짐).
고치는 법
에러 메시지의 줄 번호부터 보세요 — 자바 17 이상은 Cannot invoke "String.length()" because "<local1>" is null처럼 무엇이 null인지까지 알려 줍니다. 그다음 ① 그 변수를 new로 만들어 주거나, ② if (x != null)로 감싸거나, ③ 애초에 null을 반환하지 않도록(빈 문자열·빈 리스트를 돌려주도록) 설계를 바꿉니다. 문자열 비교라면 "확인".equals(input)처럼 확실한 값을 앞에 두는 방법도 자주 씁니다.
한 줄 요약배열의 인덱스는 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이 있다.
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).
21
cannot find symbol — "그런 이름을 못 찾겠다"
오타스코프import컴파일 에러
한 줄 요약변수·메서드·클래스 이름을 찾지 못했다는 뜻으로, 원인은 거의 항상 오타 · 선언하지 않음 · 블록 밖에서 사용 · import 누락 넷 중 하나다.
쉽게 말하면출석부에 없는 이름을 부른 것입니다. 이름을 잘못 불렀거나(오타), 아직 등록을 안 했거나(선언 안 함), 다른 반 학생을 우리 반에서 부른 것(스코프 밖)이죠.
메시지의 symbol: 줄부터 보세요
에러에 symbol: variable sum, location: class Test처럼 무엇을, 어디서 찾았는지가 같이 나옵니다. 그다음 확인 순서는 —
철자와 대소문자. 자바는 Number와 number를 다른 이름으로 봅니다.
선언 위치. for문이나 if 블록 안에서 선언한 변수는 그 { }를 벗어나면 존재하지 않습니다(Stack에서 사라짐). 밖에서도 써야 하면 블록 밖에서 선언하세요.
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 접미사).
23
ClassCastException — 참조타입을 잘못 형 변환했다
다운캐스팅instanceof다형성
한 줄 요약부모 타입으로 담아 둔 객체를 실제와 다른 자식 타입으로 강제 변환했을 때 나는 실행 중 에러다 — 컴파일러는 "가능성이 있다"고 보고 통과시키지만, 실행 시점의 진짜 정체가 다르면 터진다.
쉽게 말하면"동물"이라고 적힌 상자를 열면서 "고양이겠지" 하고 고양이 사료를 준 상황입니다. 안에 강아지가 들어 있으면 사고가 나죠. 그래서 열기 전에 "너 고양이 맞아?"라고 먼저 물어보는 것이 instanceof입니다.
안전하게 다운캐스팅하는 법
업캐스팅(자식 → 부모)은 항상 안전해서 자동으로 됩니다. 위험한 건 다운캐스팅(부모 → 자식)이라, 하기 전에 if (obj instanceof Cat) { Cat c = (Cat) obj; ... }로 확인합니다. 요즘 문법에서는 if (obj instanceof Cat c)처럼 확인과 형 변환을 한 번에 할 수도 있습니다. → 🧱 20. 참조타입 형 변환 · 🧱 18. 상속과 다형성
한 줄 요약nextInt()는 숫자만 가져가고 엔터(개행)를 버퍼에 남기기 때문에, 바로 뒤의 nextLine()이 그 개행을 읽고 빈 문자열을 반환하며 입력을 건너뛴 것처럼 보인다. 숫자를 기대한 자리에 글자가 들어오면 InputMismatchException이 난다.
쉽게 말하면줄 서 있는 사람들에게서 "숫자만 쏙 뽑아 가고 줄바꿈 표시는 그대로 두고 온 것입니다. 다음 사람이 와서 "한 줄 주세요" 하면 남아 있던 그 줄바꿈을 받아 가 버리죠 — 그래서 아무것도 못 받은 것처럼 보입니다.
가장 깔끔한 해결 — 처음부터 한 줄로 읽기
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).
✏️ 시험 대비 — Java 객관식 연습문제
자바 교안 전 범위(기초·객체지향·활용·네트워크)의 객관식 문제를 한 문제씩 풀면서 고르는 즉시 정답과 해설을 확인합니다. 짧은 해설 아래의 🔎 자세한 해설을 펼치면 보기 ①~④를 하나씩 왜 맞고 왜 틀렸는지 짚어 주고, 마지막에 더 쉬운 말로 정리한 설명까지 붙습니다(틀린 문제는 자동으로 펼쳐집니다). 마음에 걸리는 문제는 ⭐ 즐겨찾기 · 🔥 어려움으로 표시해 두면 나중에 표시한 것만 · 틀린 것만 골라 다시 풀 수 있어요. 문제은행은 교안 전 범위라 아직 배우지 않은 단원도 섞여 나오는데, 범위에서 📅 배운 데까지를 고르면 수업에서 진도가 나간 개념만 출제됩니다(진도 카드가 늘어나면 범위도 함께 넓어집니다).
분야
범위
문제 수
⌨️ 키보드로도 풀 수 있어요 — 숫자 1~4로 보기 선택, Enter로 다음 문제, S 즐겨찾기, D 어려움 표시.