[Android] 사진 찍은 후 해당 사진을 파일로 저장해 표시하기

이 글은 안드로이드에서 사진을 찍은 후 원본 이미지를 파일로 저장하고 ImageView에 해당 사진 이미지를 표시하는 예제이다.

먼저 카메라 기능 및 외부 저장소에 대한 읽기/쓰기 퍼미션을 지정하기 위해 AndroidManifest.xml에 다음 코드를 추가한다.

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-feature android:name="android.hardware.camera2" android:required="true" />

이왕 AndroidManifest.xml 파일을 편집하는 김에 Provider를 추가하자. Provider를 추가하는 이유는 외부 카메라 앱을 연동해서 사진을 찍을 건데, 카메라 앱이 사진을 찍은 후 지정된 파일에 사진 이미지를 저장하도록 하기 위해 파일에 대한 Provider가 필요하기 때문이다. 추가한 코드는 다음과 같다.

<application ... >

    <provider
        android:authorities="com.example.myapplication.fileprovider"
        android:name="androidx.core.content.FileProvider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>

    ...

위의 Provider 코드 중 android:authorities 속성값에는 App의 Package명에 “.fileprovider”를 붙여 지정하였다. 파일 Provider가 외부의 앱에 공유하고자 하는 디렉토리를 meta-data에 지정하고 있는데 xml 파일은 file_paths에 관련 정보를 지정했으며 다음과 같다. 리소스에 xml 폴더를 만든 후에 file_paths.xml 파일을 추가한 뒤 아래의 내용을 입력한다.

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="files" path="Android/data/com.example.myapplication/files" />
</paths>

위의 내용은 이 앱이 접근할 수 있는 디렉토리인 Android/data/com.example.myapplication/files의 하위 폴더 전체를 공유할 수 있다.

카메라를 실행하고 사진을 찍어 그 내용을 표시하는 UI에 대한 레이아웃은 다음과 같다.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Button
        android:layout_width="100dp"
        android:layout_height="58dp"
        android:id="@+id/btn_photo"
        android:layout_gravity="center"
        android:text="사진찍기" />


    <ImageView
        android:id="@+id/iv_photo"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

이제 이 레이아웃에 대한 Activity 코드는 다음과 같다.

package com.example.myapplication

import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.widget.Button
import android.widget.ImageView
import androidx.core.app.ActivityCompat
import androidx.core.content.FileProvider
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*

class MainActivity : AppCompatActivity() {
    val TAG = "DIP2K"
    lateinit var btn_photo: Button
    lateinit var iv_photo: ImageView

    var m_imageFile: File? = null
    val REQUEST_TAKE_PHOTO = 100

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        iv_photo = findViewById(R.id.iv_photo)
        btn_photo = findViewById(R.id.btn_photo)

        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if(checkSelfPermission(Manifest.permission.CAMERA) 
                == PackageManager.PERMISSION_GRANTED 
                && checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) 
                    == PackageManager.PERMISSION_GRANTED) {
            } else {
                ActivityCompat.requestPermissions(this,
                    arrayOf(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1)
            }
        }

        btn_photo.setOnClickListener {
            val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
            if(takePictureIntent.resolveActivity(getPackageManager()) != null) {
                createImageFile()?.let {
                    val photoURI = FileProvider.getUriForFile(this,
                        "com.example.myapplication.fileprovider", it)

                    takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI)
                    startActivityForResult(takePictureIntent, REQUEST_TAKE_PHOTO)

                    m_imageFile = it
                }
            }

        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        if(grantResults[0] == PackageManager.PERMISSION_GRANTED 
            && grantResults[1] == PackageManager.PERMISSION_GRANTED) {
            Log.d(TAG, "Permisson: " + permissions[0] + " was " + grantResults[0])
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if(requestCode == REQUEST_TAKE_PHOTO) {
            if(resultCode == RESULT_OK) {
                m_imageFile?.let {
                    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                        val source = ImageDecoder.createSource(contentResolver, Uri.fromFile(it))
                        ImageDecoder.decodeBitmap(source)?.let {
                            iv_photo.setImageBitmap(it)
                        }
                    } else {
                        MediaStore.Images.Media.getBitmap(contentResolver, Uri.fromFile(it))?.let {
                            iv_photo.setImageBitmap(it)
                        }
                    }
                }
            }
        }
    }

    private fun createImageFile(): File {
        val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
        val imageFileName = "PHOTO_${timeStamp}.jpg"
        val storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES)
        return File(storageDir, imageFileName)
    }
}

실행 결과는 다음과 같다.

[Android] 화면 터치 중 Swiping을 이용한 View 전환

화면 터치가 가능한 모바일 단말기에서 터치를 통한 UI의 조작은 매우 효과적입니다. 이러한 터치 기반의 UI의 활용에 대해 자연스러운 사용은 사용자에게 프로그램의 친밀도를 높여줍니다. 화면 터치에 대한 조작 중 Swiping은 사용자가 화면을 스치듯이 상하좌우로 쓸어넘기는 행위입니다. 이러한 Swiping 중 좌우에 대한 이벤트를 처리하기 위한 클래스는 다음과 같습니다.

package geoservice.nexgen

import android.content.Context
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View

abstract class OnSwipeTouchListener(context: Context?) : View.OnTouchListener {
    companion object {
        private const val SWIPE_DISTANCE_THRESHOLD = 100
        private const val SWIPE_VELOCITY_THRESHOLD = 100
    }

    private val gestureDetector: GestureDetector

    abstract fun onSwipeLeft()
    abstract fun onSwipeRight()

    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        return gestureDetector.onTouchEvent(event)
    }

    private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent): Boolean {
            return true
        }

        override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
            val distanceX = e2.x - e1.x
            val distanceY = e2.y - e1.y
            if (Math.abs(distanceX) > Math.abs(distanceY) 
                    && Math.abs(distanceX) > SWIPE_DISTANCE_THRESHOLD 
                    && Math.abs(velocityX) > SWIPE_VELOCITY_THRESHOLD) {
                if (distanceX > 0) onSwipeRight() else onSwipeLeft()
                return true
            }
            return false
        }
    }

    init {
        gestureDetector = GestureDetector(context, GestureListener())
    }
}

위 클래스를 실제 View에 적용하는 코드의 예는 다음과 같습니다.

llListScroll.setOnTouchListener(object: OnSwipeTouchListener(context) {
    override fun onSwipeLeft() { btnNext.performClick() }
    override fun onSwipeRight() { btnPrevious.performClick() }
})

실제 위의 코드는 모바일 기반의 GIS 솔루션인 Mobile NexGen에 반영된 코드인데요. 위의 코드와 연관된 기능에 대한 시연 영상은 아래와 같습니다.

코틀린의 observable, vetoable 위임자

코틀린은 2011년 중순에 공개되어 지속적으로 발전되어 오다가 2019년에 구글 안드로이드 개발 주요 개발언어로 채택되면서 현대적인 프로그래밍 언어중 하나입니다. 이 글은 코틀린의 데이터 변수에 obserable과 vetoable 위임자를 지정하여 변수의 값이 변경될 경우 원하는 로직을 실행하거나 변수의 값의 변경시 특정 조건과 맞지 않으면 변경을 취소하는 내용을 대해 설명합니다.

먼저 obserable 위임자를 통한 변수값 변경시 처리입니다.

import kotlin.properties.Delegates

class User {
    var name: String by Delegates.observable("초기값") {
        prop, old, new -> println("$old 값이 $new 값으로 변경됩니다.")
    }
}

fun main() {
    val user = User()
    user.name = "홍길동"
    user.name = "임꺽정"
}

실행 결과는 다음과 같습니다.

초기값 값이 홍길동 값으로 변경됩니다.
홍길동 값이 임꺽정 값으로 변경됩니다.

다음은 vetoable 위임자입니다. 값의 변경시 특정한 조건에 따라 변경을 취소할 수 있습니다.

import kotlin.properties.Delegates

class MoreBiggerInt(initValue: Int) {
    var value: Int by Delegates.vetoable(initValue) {
        property, oldValue, newValue -> {
        val result = newValue > oldValue
        if(result) {
            println("더 큰 값이므로 값을 변경합니다.")
        } else {
            println("작은 값이므로 변경을 취소합니다.")
        }
        result
    }()
    }
}

fun main() {
    val vv = MoreBiggerInt(10)

    vv.value = 20
    println("${vv.value}")

    vv.value = 5
    println("${vv.value}")
}

실행 결과는 다음과 같습니다.

더 큰 값이므로 값을 변경합니다.
20
작은 값이므로 변경을 취소합니다.
20

[GoF] Visitor 패턴

패턴명칭

Visitor

필요한 상황

데이터와 이 데이터의 처리를 분리하여 구현하고자 할때 사용되는 패턴입니다. 데이터는 Composite 패턴으로 구현되므로 집합을 구성하는 단일 요소 역시 집합으로 저장될 수 있습니다. 이러한 집합에 대한 집합으로 구성된 데이터를 처리하는 로직을 독립적으로 구현할 수 있습니다.

예제 코드

Visitor 인터페이스는 데이터를 처리하는 클래스가 구현해야할 공통 인터페이스입니다. 코드는 다음과 같습니다.

package tstThread;

public interface Visitor {
	void visit(Unit unit);
}

이 Visitor를 구현하는 클래스로는 SumVisitor, MaxVisitor과 위의 클래스 다이어그램에는 표시되어 있지 않지만 MinVisitor, AvgVisitor이 있습니다. 이 네 클래스는 각각 데이터의 총합 계산, 데이터 중 최대값 파악, 데이터 중 최소값 파악, 데이터의 평균값 계산입니다. 데이터는 Unit 인터페이스를 구현해야 하며 코드는 다음과 같습니다.

package tstThread;

public interface Unit {
	void accept(Visitor visitor);
}

이 Unit 인터페이스를 구현하는 Item에는 하나의 정수값이 저장되며 ItemList는 여러개의 Unit 객체를 담을 수 있습니다. 먼저 Item 클래스는 다음과 같습니다.

package tstThread;

public class Item implements Unit {
	private int value;
	
	public Item(int value) {
		this.value = value;
	}
	
	public int getValue() {
		return value;
	}
	
	@Override
	public void accept(Visitor visitor) {
		visitor.visit(this);
	}
}

ItemList 클래스는 다음과 같습니다.

package tstThread;

import java.util.ArrayList;
import java.util.Iterator;

public class ItemList implements Unit {
	private String name;
	
	private ArrayList<Unit> list = new ArrayList<Unit>();
	
	public ItemList(String name) {
		this.name = name;
	}
	
	public String getName() {
		return this.name;
	}
	
	public void add(Unit unit) {
		list.add(unit);
	}
	
	@Override
	public void accept(Visitor visitor) {
		Iterator<Unit> iter = list.iterator();
		
		while(iter.hasNext()) {
			Unit unit = iter.next();
			visitor.visit(unit);
		}
	}
}

이제 이러한 데이터를 처리하는 Visitor 인터페이스의 구현 클래스를 살펴보겠습니다. 먼저 SumVisitor 클래스입니다.

package tstThread;

public class SumVisitor implements Visitor {
	private int sum = 0;
	
	public int getValue() {
		return sum;
	}
	
	@Override
	public void visit(Unit unit) {
		if(unit instanceof Item) {
			sum += ((Item)unit).getValue();
		} else {
			unit.accept(this);			
		}
	}
}

다음은 MaxVisitor 클래스입니다.

package tstThread;

public class MaxVisitor implements Visitor {
	private int max = Integer.MIN_VALUE;
	private String name = null;
	private String visitedName = null;
	
	public int getValue() {
		return max;
	}
	
	public String getName() {
		return name;
	}
	
	@Override
	public void visit(Unit unit) {
		if(unit instanceof Item) {
			int value = ((Item)unit).getValue();
			if(value > max) {
				max = value;
				name = visitedName;
			}
		} else {
			visitedName = ((ItemList)unit).getName();
			unit.accept(this);			
		}
	}
}

다음은 MinVisitor 클래스입니다.

package tstThread;

public class MinVisitor implements Visitor {
	private int min = Integer.MAX_VALUE;
	private String name = null;
	private String visitedName = null;
	
	public int getValue() {
		return min;
	}
	
	public String getName() {
		return name;
	}
	
	@Override
	public void visit(Unit unit) {
		if(unit instanceof Item) {
			int value = ((Item)unit).getValue();
			if(value < min) {
				name = visitedName;
				min = value;
			}
		} else {
			visitedName = ((ItemList)unit).getName();
			unit.accept(this);			
		}
	}
}

다음은 AvgVisitor 클래스입니다.

package tstThread;

public class AvgVisitor implements Visitor {
	private int sum = 0;
	private int count = 0;
	public double getValue() {
		return sum / count;
	}
	
	@Override
	public void visit(Unit unit) {
		if(unit instanceof Item) {
			sum += ((Item)unit ).getValue();
			count++;
		} else {
			unit.accept(this);			
		}
	}
}

지금까지의 클래스를 사용하는 예제 코드는 다음과 같습니다.

package tstThread;

public class Main {
	public static void main(String[] args) {
		ItemList root = new ItemList("root");
		root.add(new Item(10));
		root.add(new Item(20));
		root.add(new Item(40));
		
		ItemList subList1 = new ItemList("sub1");
		subList1.add(new Item(5));
		subList1.add(new Item(16));
		subList1.add(new Item(36));
		
		ItemList subList2 = new ItemList("sub2");
		subList2.add(new Item(50));
		subList2.add(new Item(70));
		
		ItemList subList3 = new ItemList("sub2-sub");
		subList3.add(new Item(8));
		subList3.add(new Item(21));
		subList3.add(new Item(37));
		
		root.add(subList1);
		root.add(subList2);
		subList2.add(subList3);
		
		SumVisitor sum = new SumVisitor();
		root.accept(sum);
		System.out.println("Sum: " + sum.getValue());
		
		MaxVisitor max = new MaxVisitor();
		root.accept(max);
		System.out.println("Max: " + max.getValue() + " @" + max.getName());
		
		MinVisitor min = new MinVisitor();
		root.accept(min);
		System.out.println("Min: " + min.getValue() + " @" + min.getName());
		
		AvgVisitor avg = new AvgVisitor();
		root.accept(avg);
		System.out.println("Avg: " + avg.getValue());
	}
}

실행 결과는 다음과 같습니다.

Sum: 313
Max: 70 @sub2
Min: 5 @sub1
Avg: 28.0
이 글은 소프트웨어 설계의 기반이 되는 GoF의 디자인패턴에 대한 강의자료입니다. 완전한 실습을 위해 이 글에서 소개하는 클래스 다이어그램과 예제 코드는 완전하게 실행되도록 제공되지만, 상대적으로 예제 코드와 관련된 설명이 함축적으로 제공되고 있습니다. 이 글에 대해 궁금한 점이 있으면 댓글을 통해 남겨주시기 바랍니다.

[GoF] Interpreter 패턴

패턴명칭

Interpreter

필요한 상황

프로그램의 실행 상황을 제어할 수 있는 스크립트 언어를 지원할 수 있는 패턴이다.

예제 코드

어떤 객체를 상(Front), 하(Back), 좌(Left), 우(Right)로 이동시키는 명령어로써 각각 FRONT, BACK, LEFT, RIGHT를 사용하고 이러한 명령어들의 조합을 반복할 수 있는 LOOP 명령어를 사용할 수 있는 스크립트 언어를 해석하기 위한 예제이다. Context는 스크립트에 대한 문자열을 받아 처리하는 클래스이고, Expression은 명령어들의 해석하고 처리하기 위한 클래스들이 구현해야 하는 인터페이스이다. 먼저 Context 클래스는 다음과 같다.

package tstThread;

import java.util.StringTokenizer;

public class Context {
	private StringTokenizer tokenizer;
	private String currentKeyword;

	public Context(String script) {
		tokenizer = new StringTokenizer(script);
		readNextKeyword();
	}

	public String readNextKeyword() {
		if(tokenizer.hasMoreTokens()) {
			currentKeyword = tokenizer.nextToken();
		} else {
			currentKeyword = null;
		}
		
		return currentKeyword;
	}
	
	public String getCurrentKeyword() {
		return currentKeyword;
	}
}

Expression 인터페이스는 다음과 같다.

package tstThread;

public interface Expression {
	boolean parse(Context context);
	boolean run();
}

parse 매서드는 스크립트를 해석하고, run은 해석된 스크립트를 실제로 실행하는 매서드이다. 스크립트의 예제로 다음 문자열을 사용한다.

BEGIN FRONT LOOP 3 LOOP 2 RIGHT FRONT END LOOP 3 LEFT END BACK RIGHT END BACK END

스크립트는 BEGIN으로 시작해서 END로 끝나며, 반복문인 LOOP는 반복 회수로 시작해서 반복할 명령어들로 구성되고 END로 끝난다.

Expression 인터페이스를 구현하는 클래스들을 살펴보자. 먼저 스크립트의 시작을 해석하는 BeginExpression이다.

package tstThread;

public class BeginExpression implements Expression {
	private CommandListExpression expression;

	@Override
	public boolean parse(Context context) {
		if(checkValidKeyword(context.getCurrentKeyword())) {
			context.readNextKeyword();
			expression = new CommandListExpression();
			return expression.parse(context);
		} else {
			return false;
		}
	}

	public String toString() {
		return "BEGIN " + expression; 
	}

	@Override
	public boolean run() {
		return expression.run();
	}

	public static boolean checkValidKeyword(String keyword) {
		return keyword.equals("BEGIN");
	}
}

다음은 CommandListExpression 이다.

package tstThread;

import java.util.ArrayList;
import java.util.Iterator;

public class CommandListExpression implements Expression {
	private ArrayList<CommandExpression> commands = new ArrayList<CommandExpression>();
	
	@Override
	public boolean parse(Context context) {
		while(true) {
			String currentKeyword = context.getCurrentKeyword();
			if(currentKeyword == null) {
				return false;
			} else if(currentKeyword.equals("END")) {
				context.readNextKeyword();
				break;
			} else {
				CommandExpression command = null;
				
				if(LoopCommandExpression.checkValidKeyword(currentKeyword)) {
					command = new LoopCommandExpression(currentKeyword);
				} else if(ActionCommandExpression.checkValidKeyword(currentKeyword)) {
					command = new ActionCommandExpression(currentKeyword);
				}
				
				if(command != null) {
					if(command.parse(context)) {
						commands.add(command);
					} else {
						return false;
					}
				} else {
					return false;
				}
			}
		}
		
		return true;
	}

	public String toString() {
		return commands.toString();
	}

	@Override
	public boolean run() {
		Iterator<CommandExpression> iter = commands.iterator();
		
		while(iter.hasNext()) {
			boolean bOK = iter.next().run();
			if(!bOK) return false;
		}
		
		return true;
	}
}

CommandListExpression은 실제 실행이 가능한 LOOP나 FRONT, BACK, RIGHT, LEFT 명령어를 담을 담을 수 있는 CommandExpression의 파생클래스를 생성해 주는 책임을 진다. CommandExpression의 클래스는 다음과 같다.

package tstThread;

public abstract class CommandExpression implements Expression {
	protected String keyword;
	
	public CommandExpression(String keyword) {
		this.keyword = keyword;
	}
}

CommandExpression 추상 클래스를 상속받는 클래스들을 살펴보자. 먼저 LoopCommandExpression 클래스이다.

package tstThread;

public class LoopCommandExpression extends CommandExpression {
	private int count;
	private CommandListExpression expression;
	
	
	public LoopCommandExpression(String keyword) {
		super(keyword);
	}

	@Override
	public boolean parse(Context context) {
		if(!checkValidKeyword(keyword)) return false; 
		
		String countKeyword = context.readNextKeyword();
		if(countKeyword == null) return false;
		
		try {
			count = Integer.parseInt(countKeyword);
			expression = new CommandListExpression();
			
			if(context.readNextKeyword() == null) return false;
			
			return expression.parse(context);
		} catch(NumberFormatException e) {
			return false;
		}
	}

	public String toString() {
		return "LOOP(" + count + ") " + expression;
	}

	@Override
	public boolean run() {
		for(int i=0; i<count; i++) {
			if(!expression.run()) {
				return false;
			}
		}
		
		return true;
	}
	
	public static boolean checkValidKeyword(String keyword) {
		return keyword.equals("LOOP");
	}
}

다음은 ActionCommandExpression 클래스이다.

package tstThread;

public class ActionCommandExpression extends CommandExpression {
	public ActionCommandExpression(String keyword) {
		super(keyword);
	}

	@Override
	public boolean parse(Context context) {
		if(!checkValidKeyword(keyword)) return false;
		if(context.readNextKeyword() == null) return false;
				
		return true;
	}
	
	public String toString() {
		return keyword;
	}

	@Override
	public boolean run() {
		System.out.println("cmd: " + keyword);
		return true;
	}

	public static boolean checkValidKeyword(String keyword) {
		boolean bKeywordOk = keyword.equals("FRONT") || 
				keyword.equals("BACK") || keyword.equals("LEFT") || 
				keyword.equals("RIGHT");
		
		return bKeywordOk;
	}
}

지금까지의 클래스들을 사용하는 예제는 다음과 같다.

package tstThread;

public class Main {
	public static void main(String[] args) {
		//String script = "BEGIN FRONT LOOP 2 RIGHT FRONT LEFT LEFT BACK RIGHT END BACK END";
		//String script = "BEGIN FRONT LOOP 2 RIGHT FRONT LOOP 3 LEFT LEFT END BACK RIGHT END BACK END";
		String script = "BEGIN FRONT LOOP 3 LOOP 2 RIGHT FRONT END LOOP 3 LEFT END BACK RIGHT END BACK END";
		Context context = new Context(script);
		Expression expression = new BeginExpression();
		
		System.out.println(script);
		if(expression.parse(context)) {
			System.out.println(expression);
			expression.run();
		} else {
			System.out.println("Parsing error");
		}
	}
}

실행 결과는 다음과 같다.


BEGIN FRONT LOOP 3 LOOP 2 RIGHT FRONT END LOOP 3 LEFT END BACK RIGHT END BACK END
BEGIN [FRONT, LOOP(3) [LOOP(2) [RIGHT, FRONT], LOOP(3) [LEFT], BACK, RIGHT], BACK]
cmd: FRONT
cmd: RIGHT
cmd: FRONT
cmd: RIGHT
cmd: FRONT
cmd: LEFT
cmd: LEFT
cmd: LEFT
cmd: BACK
cmd: RIGHT
cmd: RIGHT
cmd: FRONT
cmd: RIGHT
cmd: FRONT
cmd: LEFT
cmd: LEFT
cmd: LEFT
cmd: BACK
cmd: RIGHT
cmd: RIGHT
cmd: FRONT
cmd: RIGHT
cmd: FRONT
cmd: LEFT
cmd: LEFT
cmd: LEFT
cmd: BACK
cmd: RIGHT
cmd: BACK

이 글은 소프트웨어 설계의 기반이 되는 GoF의 디자인패턴에 대한 강의자료입니다. 완전한 실습을 위해 이 글에서 소개하는 클래스 다이어그램과 예제 코드는 완전하게 실행되도록 제공되지만, 상대적으로 예제 코드와 관련된 설명이 함축적으로 제공되고 있습니다. 이 글에 대해 궁금한 점이 있으면 댓글을 통해 남겨주시기 바랍니다.